mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 |
@@ -1,31 +1,11 @@
|
|||||||
.git
|
.git
|
||||||
.debug.conversations
|
|
||||||
.debug
|
|
||||||
.dev
|
.dev
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
**/.playwright-mcp
|
|
||||||
.paseo
|
.paseo
|
||||||
**/.paseo-provider-history
|
|
||||||
.plans
|
|
||||||
.tasks
|
.tasks
|
||||||
.valknut
|
|
||||||
.claude/settings.local.json
|
|
||||||
**/.claude/settings.local.json
|
|
||||||
.claude/scheduled_tasks.lock
|
|
||||||
.claude/worktrees
|
|
||||||
.wrangler
|
.wrangler
|
||||||
**/.wrangler
|
**/.wrangler
|
||||||
**/.tanstack
|
**/.tanstack
|
||||||
PLAN.md
|
|
||||||
valknut-report.html
|
|
||||||
valknut-report.json
|
|
||||||
.env*
|
|
||||||
**/.env*
|
|
||||||
.dev.vars
|
|
||||||
**/.dev.vars
|
|
||||||
*.pem
|
|
||||||
**/*.pem
|
|
||||||
**/.secrets
|
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/build
|
**/build
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||||
|
|
||||||
@@ -168,6 +170,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
Start-Sleep -Seconds (20 * $attempt)
|
Start-Sleep -Seconds (20 * $attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
@@ -199,6 +202,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
@@ -223,6 +227,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build client dependencies
|
- name: Build client dependencies
|
||||||
run: npm run build:client
|
run: npm run build:client
|
||||||
|
|
||||||
@@ -239,13 +244,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
shard: [1, 2, 3, 4]
|
||||||
- { label: "shard 1/4", shard: 1, desktop: false }
|
name: playwright (shard ${{ matrix.shard }}/4)
|
||||||
- { label: "shard 2/4", shard: 2, desktop: false }
|
|
||||||
- { label: "shard 3/4", shard: 3, desktop: false }
|
|
||||||
- { label: "shard 4/4", shard: 4, desktop: false }
|
|
||||||
- { label: "desktop overlay", shard: "desktop", desktop: true }
|
|
||||||
name: playwright (${{ matrix.label }})
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
@@ -270,6 +270,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
@@ -281,19 +282,13 @@ jobs:
|
|||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
if: ${{ !matrix.desktop }}
|
|
||||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
if: ${{ !matrix.desktop }}
|
|
||||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||||
env:
|
env:
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
- name: Run desktop-overlay Playwright tests
|
|
||||||
if: ${{ matrix.desktop }}
|
|
||||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build app dependencies
|
- name: Build app dependencies
|
||||||
run: npm run build:app-deps
|
run: npm run build:app-deps
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,18 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Windows arm64 native search packages
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
|
||||||
|
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
|
||||||
|
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
|
||||||
|
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
|
||||||
|
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set desktop package version from tag
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,22 +5,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "docker/**"
|
- "docker/**"
|
||||||
- ".dockerignore"
|
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- "package.json"
|
|
||||||
- "package-lock.json"
|
|
||||||
- "patches/**"
|
|
||||||
- "scripts/**"
|
|
||||||
- "tsconfig.json"
|
|
||||||
- "tsconfig.base.json"
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/cli/**"
|
|
||||||
- "packages/client/**"
|
|
||||||
- "packages/expo-two-way-audio/**"
|
|
||||||
- "packages/highlight/**"
|
|
||||||
- "packages/protocol/**"
|
|
||||||
- "packages/relay/**"
|
|
||||||
- "packages/server/**"
|
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags:
|
tags:
|
||||||
@@ -28,7 +13,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
paseo_version:
|
paseo_version:
|
||||||
description: "Expected source version to build. Required when publish is true."
|
description: "Paseo version to build. Required when publish is true."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
publish:
|
publish:
|
||||||
@@ -39,6 +24,15 @@ on:
|
|||||||
options:
|
options:
|
||||||
- "false"
|
- "false"
|
||||||
- "true"
|
- "true"
|
||||||
|
source_build:
|
||||||
|
description: "Build from the checked-out source tree instead of npm."
|
||||||
|
required: false
|
||||||
|
default: "auto"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- auto
|
||||||
|
- "false"
|
||||||
|
- "true"
|
||||||
publish_latest:
|
publish_latest:
|
||||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||||
required: false
|
required: false
|
||||||
@@ -63,6 +57,7 @@ jobs:
|
|||||||
image: ${{ steps.values.outputs.image }}
|
image: ${{ steps.values.outputs.image }}
|
||||||
install_version: ${{ steps.values.outputs.install_version }}
|
install_version: ${{ steps.values.outputs.install_version }}
|
||||||
publish: ${{ steps.values.outputs.publish }}
|
publish: ${{ steps.values.outputs.publish }}
|
||||||
|
source_build: ${{ steps.values.outputs.source_build }}
|
||||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||||
steps:
|
steps:
|
||||||
@@ -73,6 +68,7 @@ jobs:
|
|||||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||||
|
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||||
REPO_OWNER: ${{ github.repository_owner }}
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -80,9 +76,9 @@ jobs:
|
|||||||
|
|
||||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||||
image="ghcr.io/${owner}/paseo"
|
image="ghcr.io/${owner}/paseo"
|
||||||
package_version="$(node -p "require('./package.json').version")"
|
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
|
||||||
publish=false
|
publish=false
|
||||||
|
source_build=false
|
||||||
publish_latest=false
|
publish_latest=false
|
||||||
prerelease=false
|
prerelease=false
|
||||||
|
|
||||||
@@ -91,6 +87,7 @@ jobs:
|
|||||||
publish=true
|
publish=true
|
||||||
if [[ "${REF_NAME}" == *-* ]]; then
|
if [[ "${REF_NAME}" == *-* ]]; then
|
||||||
prerelease=true
|
prerelease=true
|
||||||
|
source_build=true
|
||||||
else
|
else
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -107,6 +104,24 @@ jobs:
|
|||||||
prerelease=true
|
prerelease=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||||
|
true)
|
||||||
|
source_build=true
|
||||||
|
;;
|
||||||
|
false)
|
||||||
|
source_build=false
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
if [[ "${prerelease}" == "true" ]]; then
|
||||||
|
source_build=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::source_build must be auto, true, or false."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -122,13 +137,14 @@ jobs:
|
|||||||
echo "image=${image}"
|
echo "image=${image}"
|
||||||
echo "install_version=${install_version}"
|
echo "install_version=${install_version}"
|
||||||
echo "publish=${publish}"
|
echo "publish=${publish}"
|
||||||
|
echo "source_build=${source_build}"
|
||||||
echo "check_tag=${check_tag}"
|
echo "check_tag=${check_tag}"
|
||||||
echo "publish_tags<<EOF"
|
echo "publish_tags<<EOF"
|
||||||
echo "${publish_tags}"
|
echo "${publish_tags}"
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: setup
|
needs: setup
|
||||||
@@ -144,8 +160,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||||
file: docker/base/Dockerfile
|
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
@@ -177,8 +193,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||||
file: docker/base/Dockerfile
|
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ valknut-report.json/
|
|||||||
.plans/
|
.plans/
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||||
packages/protocol/src/generated/validation/*.aot.ts
|
|
||||||
|
|
||||||
/artifacts
|
/artifacts
|
||||||
packages/desktop/.cache/
|
packages/desktop/.cache/
|
||||||
|
|||||||
-192
@@ -84,198 +84,6 @@
|
|||||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
|
||||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "@tanstack/react-query",
|
|
||||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
|
||||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/data/**/*.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.test.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.spec.{ts,tsx}"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/assistant-file-links/use-file-link.ts",
|
|
||||||
"packages/app/src/components/message.tsx",
|
|
||||||
"packages/app/src/components/worktree-setup-callout-source.tsx",
|
|
||||||
"packages/app/src/desktop/hooks/use-daemon-status.ts",
|
|
||||||
"packages/app/src/desktop/hooks/use-install-status.ts",
|
|
||||||
"packages/app/src/desktop/settings/desktop-settings.ts",
|
|
||||||
"packages/app/src/git/pull-request-panel/use-data.ts",
|
|
||||||
"packages/app/src/git/use-github-search-query.ts",
|
|
||||||
"packages/app/src/git/use-pr-status-query.ts",
|
|
||||||
"packages/app/src/git/use-status-query.ts",
|
|
||||||
"packages/app/src/hooks/use-archive-agent.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-autocomplete.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-commands-query.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-history.ts",
|
|
||||||
"packages/app/src/hooks/use-branch-switcher.ts",
|
|
||||||
"packages/app/src/hooks/use-changes-preferences/index.ts",
|
|
||||||
"packages/app/src/hooks/use-draft-agent-features.ts",
|
|
||||||
"packages/app/src/hooks/use-form-preferences.ts",
|
|
||||||
"packages/app/src/hooks/use-is-local-daemon.ts",
|
|
||||||
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
|
|
||||||
"packages/app/src/hooks/use-preferred-editor.ts",
|
|
||||||
"packages/app/src/hooks/use-project-icon-query.ts",
|
|
||||||
"packages/app/src/hooks/use-settings/index.ts",
|
|
||||||
"packages/app/src/panels/terminal-panel.tsx",
|
|
||||||
"packages/app/src/projects/project-icons.ts",
|
|
||||||
"packages/app/src/provider-usage/use-provider-usage.ts",
|
|
||||||
"packages/app/src/screens/project-settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
|
|
||||||
"packages/app/src/workspace/desktop-open-targets.ts"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/app/_layout.tsx",
|
|
||||||
"packages/app/src/app/pair-scan.tsx",
|
|
||||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
|
||||||
"packages/app/src/components/add-host-method-modal.tsx",
|
|
||||||
"packages/app/src/components/add-host-modal.tsx",
|
|
||||||
"packages/app/src/components/agent-list.tsx",
|
|
||||||
"packages/app/src/components/agent-status-dot.tsx",
|
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
|
||||||
"packages/app/src/components/browser-pane.electron.tsx",
|
|
||||||
"packages/app/src/components/browser-pane.tsx",
|
|
||||||
"packages/app/src/components/browser-pane.web.tsx",
|
|
||||||
"packages/app/src/components/command-center.tsx",
|
|
||||||
"packages/app/src/components/context-window-meter.tsx",
|
|
||||||
"packages/app/src/components/dictation-controls.tsx",
|
|
||||||
"packages/app/src/components/download-toast.tsx",
|
|
||||||
"packages/app/src/components/draggable-list.native.tsx",
|
|
||||||
"packages/app/src/components/explorer-sidebar.tsx",
|
|
||||||
"packages/app/src/components/headers/back-header.tsx",
|
|
||||||
"packages/app/src/components/headers/menu-header.tsx",
|
|
||||||
"packages/app/src/components/headers/screen-header.tsx",
|
|
||||||
"packages/app/src/components/host-status-dot.tsx",
|
|
||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
|
||||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
|
||||||
"packages/app/src/components/left-sidebar.tsx",
|
|
||||||
"packages/app/src/components/pair-link-modal.tsx",
|
|
||||||
"packages/app/src/components/plan-card.tsx",
|
|
||||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
|
||||||
"packages/app/src/components/question-form-card.tsx",
|
|
||||||
"packages/app/src/components/quitting-overlay.tsx",
|
|
||||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
|
||||||
"packages/app/src/components/resize-handle.tsx",
|
|
||||||
"packages/app/src/components/rewind/rewind-menu.tsx",
|
|
||||||
"packages/app/src/components/settings-textarea.tsx",
|
|
||||||
"packages/app/src/components/sidebar-callout.tsx",
|
|
||||||
"packages/app/src/components/split-container.tsx",
|
|
||||||
"packages/app/src/components/split-drop-zone.tsx",
|
|
||||||
"packages/app/src/components/terminal-pane.tsx",
|
|
||||||
"packages/app/src/components/toast-host.tsx",
|
|
||||||
"packages/app/src/components/tool-call-sheet.tsx",
|
|
||||||
"packages/app/src/components/ui/alert.tsx",
|
|
||||||
"packages/app/src/components/ui/autocomplete.tsx",
|
|
||||||
"packages/app/src/components/ui/combobox.tsx",
|
|
||||||
"packages/app/src/components/ui/context-menu.tsx",
|
|
||||||
"packages/app/src/components/ui/dropdown-menu.tsx",
|
|
||||||
"packages/app/src/components/ui/external-link.tsx",
|
|
||||||
"packages/app/src/components/volume-meter.tsx",
|
|
||||||
"packages/app/src/components/web-desktop-scrollbar.tsx",
|
|
||||||
"packages/app/src/components/welcome-screen.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/index.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/mode-control.tsx",
|
|
||||||
"packages/app/src/constants/layout.ts",
|
|
||||||
"packages/app/src/desktop/components/desktop-permission-row.tsx",
|
|
||||||
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
|
|
||||||
"packages/app/src/desktop/components/desktop-updates-section.tsx",
|
|
||||||
"packages/app/src/desktop/components/integrations-section.tsx",
|
|
||||||
"packages/app/src/desktop/updates/update-callout-source.tsx",
|
|
||||||
"packages/app/src/git/actions-split-button.tsx",
|
|
||||||
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
|
|
||||||
"packages/app/src/hosts/host-chooser.tsx",
|
|
||||||
"packages/app/src/screens/open-project-screen.tsx",
|
|
||||||
"packages/app/src/screens/projects-screen.tsx",
|
|
||||||
"packages/app/src/screens/sessions-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings/host-page.tsx",
|
|
||||||
"packages/app/src/screens/settings/providers-section.tsx",
|
|
||||||
"packages/app/src/screens/settings/settings-group.tsx",
|
|
||||||
"packages/app/src/screens/startup-splash-screen.tsx",
|
|
||||||
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "@tanstack/react-query",
|
|
||||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
|
||||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/components/file-explorer-pane.tsx",
|
|
||||||
"packages/app/src/components/file-pane.tsx",
|
|
||||||
"packages/app/src/components/import-session-sheet.tsx",
|
|
||||||
"packages/app/src/components/project-picker-modal.tsx",
|
|
||||||
"packages/app/src/desktop/components/pair-device-section.tsx",
|
|
||||||
"packages/app/src/screens/new-workspace-screen.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files": ["**/e2e/fixtures.ts"],
|
"files": ["**/e2e/fixtures.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -1,73 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.105 - 2026-07-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
|
|
||||||
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
|
|
||||||
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
|
|
||||||
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
|
|
||||||
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
|
|
||||||
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
|
|
||||||
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
|
|
||||||
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
|
|
||||||
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
|
|
||||||
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
|
|
||||||
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
|
|
||||||
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
|
|
||||||
|
|
||||||
## 0.1.104 - 2026-07-08
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
|
|
||||||
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
|
|
||||||
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
|
|
||||||
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
|
||||||
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
|
||||||
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
|
||||||
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
|
|
||||||
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
|
|
||||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
|
||||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
|
||||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
|
||||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
|
||||||
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
|
|
||||||
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
|
|
||||||
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
|
|
||||||
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
|
|
||||||
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
|
|
||||||
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
|
|
||||||
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
|
|
||||||
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
|
||||||
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
|
||||||
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
|
||||||
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
|
|
||||||
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
|
|
||||||
|
|
||||||
## 0.1.103 - 2026-07-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
|
||||||
|
|
||||||
## 0.1.102 - 2026-06-30
|
## 0.1.102 - 2026-06-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,38 +21,34 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||||
|
|
||||||
| Doc | What's in it |
|
| Doc | What's in it |
|
||||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
|
||||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
|
||||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
|
||||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -95,13 +91,10 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
|||||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||||
- New fields: `.optional()` with a sensible default.
|
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
|
|
||||||
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
|
|
||||||
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
|
|
||||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||||
|
|||||||
+6
-30
@@ -1,33 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
|
||||||
|
|
||||||
ARG PASEO_VERSION
|
|
||||||
|
|
||||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
|
||||||
|
|
||||||
WORKDIR /tmp/paseo-src
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
if [ -n "${PASEO_VERSION:-}" ]; then \
|
|
||||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
|
||||||
fi; \
|
|
||||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
mkdir -p /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
|
||||||
|
|
||||||
FROM ${NODE_IMAGE}
|
FROM ${NODE_IMAGE}
|
||||||
|
|
||||||
|
ARG PASEO_VERSION=latest
|
||||||
|
|
||||||
ENV HOME=/home/paseo \
|
ENV HOME=/home/paseo \
|
||||||
PASEO_HOME=/home/paseo/.paseo \
|
PASEO_HOME=/home/paseo/.paseo \
|
||||||
PASEO_LISTEN=0.0.0.0:6767 \
|
PASEO_LISTEN=0.0.0.0:6767 \
|
||||||
@@ -52,14 +29,13 @@ RUN set -eux; \
|
|||||||
gosu \
|
gosu \
|
||||||
lbzip2 \
|
lbzip2 \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
procps \
|
|
||||||
tini; \
|
tini; \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
npm install -g /tmp/paseo-packs/*.tgz; \
|
npm install -g --omit=optional \
|
||||||
rm -rf /tmp/paseo-packs; \
|
"@getpaseo/server@${PASEO_VERSION}" \
|
||||||
|
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||||
npm cache clean --force; \
|
npm cache clean --force; \
|
||||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||||
test -f "$server_entry"; \
|
test -f "$server_entry"; \
|
||||||
@@ -90,7 +66,7 @@ RUN set -eux; \
|
|||||||
"$XDG_CACHE_HOME"; \
|
"$XDG_CACHE_HOME"; \
|
||||||
chown -R paseo:paseo /home/paseo /workspace
|
chown -R paseo:paseo /home/paseo /workspace
|
||||||
|
|
||||||
COPY docker/base/rootfs/ /
|
COPY rootfs/ /
|
||||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||||
|
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||||
|
|
||||||
|
ARG PASEO_VERSION
|
||||||
|
|
||||||
|
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||||
|
|
||||||
|
WORKDIR /tmp/paseo-src
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||||
|
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
mkdir -p /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE}
|
||||||
|
|
||||||
|
ENV HOME=/home/paseo \
|
||||||
|
PASEO_HOME=/home/paseo/.paseo \
|
||||||
|
PASEO_LISTEN=0.0.0.0:6767 \
|
||||||
|
PASEO_WEB_UI_ENABLED=true \
|
||||||
|
PASEO_LOG_FORMAT=json \
|
||||||
|
PASEO_LOG_LEVEL=info \
|
||||||
|
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||||
|
CODEX_HOME=/home/paseo/.codex \
|
||||||
|
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||||
|
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||||
|
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||||
|
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||||
|
ONNXRUNTIME_NODE_INSTALL=skip
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
gosu \
|
||||||
|
lbzip2 \
|
||||||
|
openssh-client \
|
||||||
|
tini; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||||
|
RUN set -eux; \
|
||||||
|
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
|
||||||
|
rm -rf /tmp/paseo-packs; \
|
||||||
|
npm cache clean --force; \
|
||||||
|
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||||
|
test -f "$server_entry"; \
|
||||||
|
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||||
|
node --check "$server_entry"
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||||
|
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||||
|
groupmod --new-name paseo "$existing_group"; \
|
||||||
|
elif [ -z "$existing_group" ]; then \
|
||||||
|
groupadd --gid 1000 paseo; \
|
||||||
|
fi; \
|
||||||
|
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||||
|
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||||
|
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||||
|
elif [ -z "$existing_user" ]; then \
|
||||||
|
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||||
|
fi; \
|
||||||
|
mkdir -p \
|
||||||
|
/workspace \
|
||||||
|
"$PASEO_HOME" \
|
||||||
|
"$CLAUDE_CONFIG_DIR" \
|
||||||
|
"$CODEX_HOME" \
|
||||||
|
"$XDG_CONFIG_HOME" \
|
||||||
|
"$XDG_DATA_HOME" \
|
||||||
|
"$XDG_STATE_HOME" \
|
||||||
|
"$XDG_CACHE_HOME"; \
|
||||||
|
chown -R paseo:paseo /home/paseo /workspace
|
||||||
|
|
||||||
|
COPY docker/base/rootfs/ /
|
||||||
|
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
EXPOSE 6767
|
||||||
|
VOLUME ["/home/paseo"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||||
+1
-10
@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
|
|||||||
|
|
||||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||||
>
|
>
|
||||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||||
|
|
||||||
### `packages/website` — Marketing site
|
### `packages/website` — Marketing site
|
||||||
|
|
||||||
@@ -185,15 +185,6 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
|||||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||||
|
|
||||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
|
||||||
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
|
||||||
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
|
||||||
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
|
||||||
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
|
||||||
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
|
||||||
correlated daemon response. The legacy `directories` response field remains a projection of the
|
|
||||||
typed `entries` list.
|
|
||||||
|
|
||||||
**Binary frames (terminal stream protocol):**
|
**Binary frames (terminal stream protocol):**
|
||||||
|
|
||||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Browser Capture Harness
|
|
||||||
|
|
||||||
The desktop capture harness is the real-Electron verification path for browser screenshots.
|
|
||||||
It validates the compositor behavior that unit tests cannot see:
|
|
||||||
|
|
||||||
- the resident automation `<webview>` starts in the production parking state;
|
|
||||||
- the parked guest remains paintable and has a copyable viewport frame;
|
|
||||||
- the resident webview guest is sized to 1280x800 logical pixels;
|
|
||||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
|
||||||
stacking changes;
|
|
||||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
|
||||||
by retrying until the frame appears;
|
|
||||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
|
||||||
the permanent production parking state;
|
|
||||||
- guest background throttling can be disabled once at attach without per-capture
|
|
||||||
renderer coordination.
|
|
||||||
|
|
||||||
Run it with the repo Electron:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run capture-harness --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the browser automation fixture with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
The automation group uses a real guest webview to verify the page-side ref contract:
|
|
||||||
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
|
||||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
|
||||||
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
|
||||||
page-context evaluation, including passing a resolved ref element as the function argument.
|
|
||||||
|
|
||||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
|
||||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
|
||||||
focus; a normal Electron app launch can still activate the app and steal focus.
|
|
||||||
Harness windows are then created hidden, positioned in a screen corner, skipped from
|
|
||||||
the taskbar where Electron supports it, and revealed with `showInactive()` from
|
|
||||||
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
|
|
||||||
the compositor only needs visible inactive windows, and harness runs must not steal
|
|
||||||
focus from the person using the machine.
|
|
||||||
|
|
||||||
The harness writes PNG evidence and `results.json` to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/desktop/capture-harness/out/
|
|
||||||
```
|
|
||||||
|
|
||||||
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
|
||||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
|
||||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
|
||||||
is usually saved as 2560x1600.
|
|
||||||
|
|
||||||
## Mechanism
|
|
||||||
|
|
||||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
|
||||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
|
||||||
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
|
||||||
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
|
||||||
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
|
||||||
overlap at `left:0`, `top:0`.
|
|
||||||
|
|
||||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
|
||||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
|
||||||
invalidates before each attempt, and retries known first-frame failures within the
|
|
||||||
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
|
||||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
|
||||||
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
| `qwen3-coder-next` | Optimized for coding |
|
| `qwen3-coder-next` | Optimized for coding |
|
||||||
| `kimi-k2.5` | Vision capable |
|
| `kimi-k2.5` | Vision capable |
|
||||||
| `glm-5` | Zhipu GLM |
|
| `glm-5` | Zhipu GLM |
|
||||||
| `MiniMax-M3` | MiniMax |
|
| `MiniMax-M2.5` | MiniMax |
|
||||||
|
|
||||||
**Additional models (pay-as-you-go):**
|
**Additional models (pay-as-you-go):**
|
||||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
|
|||||||
|
|
||||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
|
|
||||||
## Store Surface Rules
|
|
||||||
|
|
||||||
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|||||||
+6
-26
@@ -72,10 +72,6 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
|
|||||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||||
|
|
||||||
When running a dedicated Electron QA instance against a non-default Expo port, set
|
|
||||||
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
|
|
||||||
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
|
|
||||||
|
|
||||||
### React render profiling
|
### React render profiling
|
||||||
|
|
||||||
The app has a gated React render profiler in
|
The app has a gated React render profiler in
|
||||||
@@ -173,33 +169,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
|||||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||||
|
|
||||||
### Agent Tool Catalog Measurement
|
### Workspace search
|
||||||
|
|
||||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
|
||||||
|
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
|
||||||
```bash
|
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
|
||||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
matcher in Paseo. If ignore semantics need to change, change the backend
|
||||||
```
|
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
|
||||||
|
|
||||||
The command reports compact JSON bytes, estimated tokens, field totals, largest
|
|
||||||
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
|
|
||||||
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
|
|
||||||
for machine-readable output.
|
|
||||||
|
|
||||||
## paseo.json service scripts
|
## paseo.json service scripts
|
||||||
|
|
||||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||||
of commands. Both run sequentially.
|
of commands. Both run sequentially.
|
||||||
|
|
||||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
|
||||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
|
||||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
|
||||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
|
||||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
|
||||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
|
||||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
|
||||||
they launch in a terminal and receive the service environment described below.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"worktree": {
|
"worktree": {
|
||||||
@@ -303,8 +285,6 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
|
|||||||
|
|
||||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||||
|
|
||||||
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
|
|
||||||
|
|
||||||
For tighter loops, you can rebuild a single workspace:
|
For tighter loops, you can rebuild a single workspace:
|
||||||
|
|
||||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||||
|
|||||||
+10
-9
@@ -10,7 +10,8 @@ The image source lives in [`docker/`](../docker/).
|
|||||||
|
|
||||||
The official image:
|
The official image:
|
||||||
|
|
||||||
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||||
|
or from source-built workspace tarballs for beta images
|
||||||
- runs the daemon as the non-root `paseo` user
|
- runs the daemon as the non-root `paseo` user
|
||||||
- listens on `0.0.0.0:6767` inside the container
|
- listens on `0.0.0.0:6767` inside the container
|
||||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||||
@@ -189,17 +190,16 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
|||||||
## Building Locally
|
## Building Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f docker/base/Dockerfile -t paseo:local .
|
docker build -t paseo:local docker/base
|
||||||
```
|
```
|
||||||
|
|
||||||
To assert the source tree version while building:
|
To bake a specific published npm version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg PASEO_VERSION=0.1.102 \
|
--build-arg PASEO_VERSION=0.1.102 \
|
||||||
-t paseo:0.1.102 \
|
-t paseo:0.1.102 \
|
||||||
-f docker/base/Dockerfile \
|
docker/base
|
||||||
.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker workflow builds the image on pull requests and on `main` as a
|
The Docker workflow builds the image on pull requests and on `main` as a
|
||||||
@@ -216,12 +216,13 @@ pushing a `v*` release tag:
|
|||||||
gh workflow run docker.yml \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=0.1.102-beta.1 \
|
-f paseo_version=0.1.102-beta.1 \
|
||||||
-f publish=true
|
-f publish=true \
|
||||||
|
-f source_build=auto
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||||
from the checked-out source tree and publishes only the exact prerelease image
|
versions build from the checked-out source tree by default and publish only the
|
||||||
tag for prerelease versions.
|
exact prerelease image tag.
|
||||||
|
|
||||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
|||||||
+7
-15
@@ -47,27 +47,18 @@ dynamic params exist before any nested workspace leaf is selected.
|
|||||||
|
|
||||||
## App-Wide Route Hops
|
## App-Wide Route Hops
|
||||||
|
|
||||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||||
into a host workspace, express only the destination with `navigateToWorkspace()`.
|
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||||
Do not make the caller branch on its current route.
|
leaf workspace URL.
|
||||||
|
|
||||||
The root stack owns `h/[serverId]`; the host stack owns
|
The root stack owns `h/[serverId]`; the host stack owns
|
||||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||||
root host route and pass the nested workspace screen when a host route is
|
root host route and pass the nested workspace screen, or Expo Router can append
|
||||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
extra hidden workspace deck entries.
|
||||||
The workspace navigation helper inspects the mounted navigation state to make
|
|
||||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
|
||||||
navigation.
|
|
||||||
|
|
||||||
Those hidden entries are not harmless: composer floating panels can measure
|
Those hidden entries are not harmless: composer floating panels can measure
|
||||||
against the wrong deck and disappear offscreen.
|
against the wrong deck and disappear offscreen.
|
||||||
|
|
||||||
Hidden host routes may keep their local params while an app-wide route is
|
|
||||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
|
||||||
only use local param fallback during cold mount (`/` or empty pathname), or a
|
|
||||||
hidden workspace can overwrite the remembered workspace before Settings or
|
|
||||||
History returns.
|
|
||||||
|
|
||||||
## Params
|
## Params
|
||||||
|
|
||||||
Required dynamic params belong to the matched route.
|
Required dynamic params belong to the matched route.
|
||||||
@@ -121,7 +112,8 @@ Before landing route changes:
|
|||||||
|
|
||||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||||
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
|
- [ ] Did an app-wide route return to a workspace? Use
|
||||||
|
`navigateToHostWorkspaceRoute()`.
|
||||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||||
|
|||||||
@@ -20,29 +20,6 @@ input focused while its scrollable list lives in a Portal. There is no shared
|
|||||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||||
until then prefer copying the closest file and trimming.
|
until then prefer copying the closest file and trimming.
|
||||||
|
|
||||||
## Popover width contract
|
|
||||||
|
|
||||||
Combobox desktop popovers are never narrower than their trigger, and they grow
|
|
||||||
with content up to a ceiling that is never below the trigger:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
|
||||||
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
|
|
||||||
```
|
|
||||||
|
|
||||||
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
|
|
||||||
trigger still wins when it is wider. Changing this default requires re-verifying
|
|
||||||
every consumer listed here.
|
|
||||||
|
|
||||||
Consumers: `composer/agent-controls/mode-control.tsx`,
|
|
||||||
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
|
|
||||||
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
|
|
||||||
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
|
|
||||||
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
|
|
||||||
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
|
|
||||||
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
|
|
||||||
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
|
|
||||||
|
|
||||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||||
|
|
||||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# Forms
|
|
||||||
|
|
||||||
The paved road for building forms in the app. The schedule form is the golden
|
|
||||||
example; when building or fixing any form, copy its shape, not the shape of
|
|
||||||
whatever screen you happen to be near.
|
|
||||||
|
|
||||||
Golden example files:
|
|
||||||
|
|
||||||
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
|
|
||||||
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
|
|
||||||
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
|
|
||||||
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
|
|
||||||
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
|
|
||||||
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
|
|
||||||
|
|
||||||
## The form model
|
|
||||||
|
|
||||||
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
|
|
||||||
|
|
||||||
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
|
|
||||||
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
|
|
||||||
AND display from the snapshot — never from a previous instance.
|
|
||||||
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
|
|
||||||
Derived state (disclosure, canSubmit, displays) is recomputed inside the
|
|
||||||
model on every publish.
|
|
||||||
- `close()` destroys the instance. `subscribe`/`getState` feed one
|
|
||||||
`useSyncExternalStore` in the component.
|
|
||||||
|
|
||||||
The component renders state and dispatches intent. That is all it does.
|
|
||||||
|
|
||||||
### Lifecycle rules (each one killed a real shipped bug)
|
|
||||||
|
|
||||||
1. **Fresh mount per open.** The sheet returns `null` when not visible and
|
|
||||||
mounts the open form with a `key` derived from mode + record identity.
|
|
||||||
A long-lived component instance shared across create/edit is how edit
|
|
||||||
contaminated create.
|
|
||||||
2. **Construct the model ONCE per mount** — `useState(() => openXxxForm(snapshot))`.
|
|
||||||
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
|
|
||||||
on live data (projects, hosts, preferences), and any background churn — e.g.
|
|
||||||
a scheduled run creating a workspace — would reconstruct the model and wipe
|
|
||||||
the user's in-progress input.
|
|
||||||
3. **Late data is an explicit model input, not a reconstruction.**
|
|
||||||
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
|
|
||||||
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
|
|
||||||
effects. Input plumbing is fine; orchestration effects are not — the sheet
|
|
||||||
itself has zero `useEffect`/`useRef`, and that is the target for every form.
|
|
||||||
4. **Resolution is explicit model state, per host** (`idle | pending |
|
|
||||||
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
|
|
||||||
state you can render, not an effect race.
|
|
||||||
5. **Displays are owned state.** The selected option's label is captured at
|
|
||||||
selection/seed time (`setProject(value, display)`), never re-derived from a
|
|
||||||
live options list — list churn must not flicker or blank a selection.
|
|
||||||
6. **Disclosure is derived in the model** from user intent
|
|
||||||
(host → project → model → thinking/mode), so fields cannot pop in from
|
|
||||||
cache timing.
|
|
||||||
|
|
||||||
## Form kit
|
|
||||||
|
|
||||||
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
|
|
||||||
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
|
|
||||||
states) is owned by `components/ui/control-geometry.ts` — controls never
|
|
||||||
declare their own, and screens never nudge global component styles to align
|
|
||||||
a row.
|
|
||||||
- The form declares one size for all fields: `sm` on desktop, `md` compact
|
|
||||||
(`useIsCompactFormFactor`).
|
|
||||||
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
|
|
||||||
(isolation on a non-git project — same gating as New Workspace), not rendered
|
|
||||||
disabled with an explanation. Disabled-with-a-reason `hint` is only for
|
|
||||||
transient states the user can resolve.
|
|
||||||
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
|
|
||||||
exact string; validation errors are the exception. State a fact (like the
|
|
||||||
timezone) once — never in a preview line AND a helper line.
|
|
||||||
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
|
|
||||||
|
|
||||||
## Data gating
|
|
||||||
|
|
||||||
Aggregate hooks return a discriminated load state:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type AggregateLoadState<T> =
|
|
||||||
| { status: "connecting" } // an answer may still be pending
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; data: T[] };
|
|
||||||
```
|
|
||||||
|
|
||||||
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
|
|
||||||
before hosts connected is `connecting`, not empty. Query keys carry real fetch
|
|
||||||
inputs (host set, connection statuses), never synthetic version counters.
|
|
||||||
|
|
||||||
## Anti-patterns (reject in review on sight)
|
|
||||||
|
|
||||||
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
|
|
||||||
- One mounted form instance serving create and edit.
|
|
||||||
- `useMemo`-keyed model construction on live-data identity.
|
|
||||||
- Selected labels derived from live query lists.
|
|
||||||
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
|
|
||||||
- Conditional mounting of hint/error rows that shifts layout (subtext renders
|
|
||||||
only when present, but the pattern for that lives in `Field`, not ad hoc).
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Mobile panels
|
|
||||||
|
|
||||||
Compact layouts have three mutually exclusive destinations:
|
|
||||||
|
|
||||||
- `agent-list` on the left
|
|
||||||
- `agent` in the center
|
|
||||||
- `file-explorer` on the right
|
|
||||||
|
|
||||||
They are one interaction, not two independent drawers. The implementation lives in
|
|
||||||
`packages/app/src/mobile-panels/`.
|
|
||||||
|
|
||||||
## Ownership
|
|
||||||
|
|
||||||
React/Zustand owns the durable intent:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface MobilePanelSelection {
|
|
||||||
target: "agent-list" | "agent" | "file-explorer";
|
|
||||||
revision: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
|
||||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
|
||||||
|
|
||||||
The UI worklet owns transient motion:
|
|
||||||
|
|
||||||
- one normalized position (`-1` left, `0` center, `1` right)
|
|
||||||
- the current motion target
|
|
||||||
- the active gesture's starting revision
|
|
||||||
- the last settled target
|
|
||||||
|
|
||||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
|
||||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
|
||||||
|
|
||||||
## Why one position
|
|
||||||
|
|
||||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
|
||||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
|
||||||
|
|
||||||
This makes these invalid states unrepresentable:
|
|
||||||
|
|
||||||
- a panel and its backdrop disagreeing
|
|
||||||
- left and right drawers both claiming to be open
|
|
||||||
- a width-sync effect resetting an active drag
|
|
||||||
- one animation context settling a transition owned by the other
|
|
||||||
|
|
||||||
Do not add another panel translate shared value, backdrop shared value, or width synchronization
|
|
||||||
effect.
|
|
||||||
|
|
||||||
## Ordering and interruption
|
|
||||||
|
|
||||||
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
|
|
||||||
while that revision still owns the gesture.
|
|
||||||
|
|
||||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
|
||||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
|
||||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
|
||||||
its target and revision still match the canonical command.
|
|
||||||
|
|
||||||
Manual gesture arbitration has two phases:
|
|
||||||
|
|
||||||
1. Before activation, determine whether horizontal intent may begin.
|
|
||||||
2. After activation, stop running begin checks and let the active revision own updates.
|
|
||||||
|
|
||||||
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
|
|
||||||
definition, no longer eligible to begin.
|
|
||||||
|
|
||||||
## Integration rules
|
|
||||||
|
|
||||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
|
||||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
|
||||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
|
||||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
|
||||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
|
||||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
|
||||||
styles in sidebar components.
|
|
||||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
|
||||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
|
||||||
has caused native crashes.
|
|
||||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
|
||||||
prop commit from resurrecting a closed overlay.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
|
||||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
|
||||||
sequence there whenever ownership or ordering changes.
|
|
||||||
@@ -198,19 +198,6 @@ for i in $(seq 1 "$ITERATIONS"); do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Android audio focus interruptions
|
|
||||||
|
|
||||||
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
adb shell am start -n sh.paseo/.MainActivity
|
|
||||||
# Start voice mode in an existing composer, then background Paseo with Home.
|
|
||||||
adb emu gsm call 5551234
|
|
||||||
# Foreground Paseo while the call is still ringing.
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
|
||||||
|
|
||||||
## Unistyles + Reanimated
|
## Unistyles + Reanimated
|
||||||
|
|
||||||
### The crash
|
### The crash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- `opencode --version`: `1.14.46`
|
- `opencode --version`: `1.14.46`
|
||||||
- `which opencode`: `opencode`
|
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||||
- `node --version`: `v22.20.0`
|
- `node --version`: `v22.20.0`
|
||||||
- `npm --version`: `10.9.3`
|
- `npm --version`: `10.9.3`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ Anyone who builds software:
|
|||||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||||
- Voice mode: dictate prompts or talk through problems hands-free
|
- Voice mode: dictate prompts or talk through problems hands-free
|
||||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
|
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||||
- Frequent releases (multiple per week)
|
- Frequent releases (multiple per week)
|
||||||
- Community contributions across packaging, providers, and bug fixes
|
- Community contributions across packaging, providers, and bug fixes
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# Protocol Validation
|
|
||||||
|
|
||||||
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
|
|
||||||
|
|
||||||
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
|
|
||||||
|
|
||||||
## Runtime Path
|
|
||||||
|
|
||||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
|
||||||
|
|
||||||
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
|
||||||
|
|
||||||
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
|
|
||||||
|
|
||||||
## Codegen Ownership
|
|
||||||
|
|
||||||
The protocol package owns generation.
|
|
||||||
|
|
||||||
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
|
||||||
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
|
||||||
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
|
||||||
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
|
||||||
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
|
||||||
|
|
||||||
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
|
||||||
|
|
||||||
## Regression Tests
|
|
||||||
|
|
||||||
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
|
|
||||||
|
|
||||||
- discriminated-union branch output must propagate `.default()` fields
|
|
||||||
- current sequential item routing must accept `tool_call`-like status branches
|
|
||||||
- generated runtime imports must keep `.js` extensions for packaged Node ESM
|
|
||||||
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
|
|
||||||
|
|
||||||
## Schema Purity
|
|
||||||
|
|
||||||
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
|
|
||||||
|
|
||||||
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
|
|
||||||
|
|
||||||
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
|
|
||||||
@@ -18,16 +18,12 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
|||||||
|
|
||||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||||
|
|
||||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
|
||||||
|
|
||||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||||
|
|
||||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||||
|
|
||||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||||
|
|
||||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
|
||||||
|
|
||||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||||
|
|
||||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||||
|
|||||||
+3
-2
@@ -50,7 +50,7 @@ npm run release:patch
|
|||||||
|
|
||||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||||
|
|
||||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
|
||||||
|
|
||||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||||
|
|
||||||
@@ -281,7 +281,8 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
|||||||
gh workflow run docker.yml \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=X.Y.Z-beta.N \
|
-f paseo_version=X.Y.Z-beta.N \
|
||||||
-f publish=true
|
-f publish=true \
|
||||||
|
-f source_build=auto
|
||||||
```
|
```
|
||||||
|
|
||||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||||
|
|||||||
@@ -115,8 +115,6 @@ App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs
|
|||||||
|
|
||||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||||
|
|
||||||
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
|
|
||||||
|
|
||||||
### Test setup
|
### Test setup
|
||||||
|
|
||||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-RtZABQl17BlUyvFiFw9W5g2E/dpNlrOWpuCdV8wZk5o=
|
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=
|
||||||
|
|||||||
Generated
+360
-49
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -6154,6 +6154,141 @@
|
|||||||
"excpretty": "build/cli.js"
|
"excpretty": "build/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-darwin-x64": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-win32-arm64": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-bin-win32-x64": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@ff-labs/fff-node": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"os": [
|
||||||
|
"darwin",
|
||||||
|
"linux",
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"ffi-rs": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@ff-labs/fff-bin-darwin-arm64": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-darwin-x64": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-linux-arm64-musl": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-linux-x64-gnu": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-linux-x64-musl": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-win32-arm64": "0.9.6",
|
||||||
|
"@ff-labs/fff-bin-win32-x64": "0.9.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
@@ -13761,6 +13896,183 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-android-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-darwin-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64",
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/7zip-bin": {
|
"node_modules/7zip-bin": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||||
@@ -21209,6 +21521,25 @@
|
|||||||
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
|
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ffi-rs": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@yuuang/ffi-rs-android-arm64": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-darwin-x64": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-linux-x64-musl": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2",
|
||||||
|
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fflate": {
|
"node_modules/fflate": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
@@ -21797,9 +22128,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.14.0",
|
"version": "4.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -28563,9 +28894,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -35069,26 +35400,6 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod-aot": {
|
|
||||||
"version": "0.20.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
|
|
||||||
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"acorn": "^8.16.0",
|
|
||||||
"get-tsconfig": "^4.14.0",
|
|
||||||
"jiti": "^2.7.0",
|
|
||||||
"picomatch": "^4.0.4",
|
|
||||||
"unplugin": "^3.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"zod-aot": "dist/cli/index.js"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.25.1",
|
"version": "3.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||||
@@ -35152,7 +35463,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -36170,12 +36481,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.105",
|
"@getpaseo/client": "0.1.102",
|
||||||
"@getpaseo/protocol": "0.1.105",
|
"@getpaseo/protocol": "0.1.102",
|
||||||
"@getpaseo/server": "0.1.105",
|
"@getpaseo/server": "0.1.102",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36421,10 +36732,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.105",
|
"@getpaseo/protocol": "0.1.102",
|
||||||
"@getpaseo/relay": "0.1.105",
|
"@getpaseo/relay": "0.1.102",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36435,7 +36746,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36678,7 +36989,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37574,7 +37885,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37806,20 +38117,19 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6"
|
||||||
"zod-aot": "0.20.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38037,15 +38347,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.105",
|
"@ff-labs/fff-node": "^0.9.6",
|
||||||
"@getpaseo/highlight": "0.1.105",
|
"@getpaseo/client": "0.1.102",
|
||||||
"@getpaseo/protocol": "0.1.105",
|
"@getpaseo/highlight": "0.1.102",
|
||||||
"@getpaseo/relay": "0.1.105",
|
"@getpaseo/protocol": "0.1.102",
|
||||||
|
"@getpaseo/relay": "0.1.102",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38582,7 +38893,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||||
import {
|
import {
|
||||||
@@ -5,7 +7,7 @@ import {
|
|||||||
seedMockAgentWorkspace,
|
seedMockAgentWorkspace,
|
||||||
type MockAgentWorkspace,
|
type MockAgentWorkspace,
|
||||||
} from "./helpers/mock-agent";
|
} from "./helpers/mock-agent";
|
||||||
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
|
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||||
@@ -153,13 +155,6 @@ async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
|||||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
|
|
||||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
|
|
||||||
await page.getByTestId("settings-back-to-workspace").click();
|
|
||||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||||
@@ -216,6 +211,25 @@ async function openReadyMockAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
|
||||||
|
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
|
||||||
|
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
|
||||||
|
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectFileMentionSuggestion(
|
||||||
|
page: Page,
|
||||||
|
query: string,
|
||||||
|
suggestion: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const input = composerLocator(page);
|
||||||
|
await input.fill(query);
|
||||||
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||||
|
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function visiblePopoverBox(
|
async function visiblePopoverBox(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ top: number; bottom: number; height: number }> {
|
): Promise<{ top: number; bottom: number; height: number }> {
|
||||||
@@ -358,7 +372,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Composer autocomplete", () => {
|
test.describe("Composer autocomplete", () => {
|
||||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
|
||||||
await installListCommandsStub(page);
|
await installListCommandsStub(page);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const sessions: MockAgentWorkspace[] = [];
|
const sessions: MockAgentWorkspace[] = [];
|
||||||
@@ -391,28 +405,10 @@ test.describe("Composer autocomplete", () => {
|
|||||||
await openAppWideNewWorkspace(page);
|
await openAppWideNewWorkspace(page);
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
||||||
expectedDeckEntryCount: 2,
|
|
||||||
serverId,
|
|
||||||
workspaceId: second.workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openSettingsThenBackToWorkspace(page);
|
await openAppWideNewWorkspace(page);
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
|
||||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
||||||
expectedDeckEntryCount: 2,
|
|
||||||
serverId,
|
|
||||||
workspaceId: second.workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openSessions(page);
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
||||||
expectedDeckEntryCount: sessions.length,
|
|
||||||
serverId,
|
|
||||||
workspaceId: third.workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openAppWideNewWorkspace(page);
|
await openAppWideNewWorkspace(page);
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||||
@@ -544,6 +540,28 @@ test.describe("Composer autocomplete", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "autocomplete-dot-entries-",
|
||||||
|
title: "Dot file mention autocomplete",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedDotPrefixedWorkspaceFiles(session.cwd);
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
|
||||||
|
const input = composerLocator(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expectFileMentionSuggestion(page, "@.env", ".env.local");
|
||||||
|
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||||
await installListCommandsStub(page);
|
await installListCommandsStub(page);
|
||||||
const agent = await openReadyMockAgent(page);
|
const agent = await openReadyMockAgent(page);
|
||||||
|
|||||||
@@ -218,38 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
|
||||||
await useUnwrappedDiffLines(page);
|
|
||||||
await openWorkspaceChanges(page, workspace);
|
|
||||||
|
|
||||||
await expectFlatFileList(page);
|
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("changes-options-menu").click();
|
|
||||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
|
||||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
|
||||||
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
|
|
||||||
await page.getByTestId("changes-toggle-whitespace").click();
|
|
||||||
await page.getByTestId("changes-options-menu").click();
|
|
||||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
await scrollToLowerUnwrappedDiffRows(page);
|
|
||||||
await page.getByTestId("changes-toggle-view-mode").click();
|
|
||||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByTestId("diff-folder-src-toggle").click();
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-view-mode").click();
|
|
||||||
await expectFlatFileList(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -298,24 +266,13 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
|||||||
({ preferencesKey }) => {
|
({ preferencesKey }) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
preferencesKey,
|
preferencesKey,
|
||||||
JSON.stringify({
|
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
||||||
layout: "unified",
|
|
||||||
viewMode: "flat",
|
|
||||||
wrapLines: false,
|
|
||||||
hideWhitespace: false,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFlatFileList(page: Page): Promise<void> {
|
|
||||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
|
||||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
@@ -51,7 +50,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
|||||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
const input = page.getByTestId("project-picker-input");
|
const input = page.getByPlaceholder("Type a directory path...");
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
await input.fill(projectPath);
|
await input.fill(projectPath);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
@@ -75,26 +74,6 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Project picker search", () => {
|
test.describe("Project picker search", () => {
|
||||||
test("opens a project from a fuzzy directory-name search", async ({
|
|
||||||
page,
|
|
||||||
projectPickerFixture,
|
|
||||||
}) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarProjectListReady(page);
|
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
|
||||||
|
|
||||||
const input = page.getByTestId("project-picker-input");
|
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
|
||||||
await input.fill(projectPickerFixture.fuzzyQuery);
|
|
||||||
|
|
||||||
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
|
|
||||||
await expect(suggestion).toBeVisible({ timeout: 30_000 });
|
|
||||||
await suggestion.click();
|
|
||||||
|
|
||||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
|
||||||
projectPickerFixture.rememberProjectId(projectId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -102,7 +81,7 @@ test.describe("Project picker search", () => {
|
|||||||
await waitForSidebarProjectListReady(page);
|
await waitForSidebarProjectListReady(page);
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
const input = page.getByTestId("project-picker-input");
|
const input = page.getByPlaceholder("Type a directory path...");
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
await input.fill("paseo-loading-state-no-match");
|
await input.fill("paseo-loading-state-no-match");
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,14 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
import {
|
|
||||||
createProjectPickerFixture,
|
|
||||||
removeProjectPickerFixture,
|
|
||||||
type ProjectPickerFixture,
|
|
||||||
} from "./helpers/project-picker-fixture";
|
|
||||||
import { connectSeedClient } from "./helpers/seed-client";
|
|
||||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
|
||||||
|
|
||||||
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
|
||||||
rememberProjectId: (projectId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||||
// reliably for every test that uses this `test` object.
|
// reliably for every test that uses this `test` object.
|
||||||
const test = base.extend<{
|
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||||
paseoE2ESetup: void;
|
|
||||||
projectPickerFixture: TrackedProjectPickerFixture;
|
|
||||||
withWorkspace: WithWorkspace;
|
|
||||||
}>({
|
|
||||||
baseURL: async ({}, provide) => {
|
baseURL: async ({}, provide) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
@@ -73,7 +57,7 @@ const test = base.extend<{
|
|||||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
({ daemon, preferences, seedNonce: nonce }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
@@ -89,20 +73,12 @@ const test = base.extend<{
|
|||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||||
|
|
||||||
const rawExtraHosts = localStorage.getItem(extraHostsKey);
|
|
||||||
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
|
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
localStorage.removeItem("@paseo:settings");
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
},
|
},
|
||||||
{
|
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||||
daemon: testDaemon,
|
|
||||||
preferences: createAgentPreferences,
|
|
||||||
seedNonce,
|
|
||||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await provide();
|
await provide();
|
||||||
@@ -116,30 +92,6 @@ const test = base.extend<{
|
|||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
projectPickerFixture: async ({}, provide) => {
|
|
||||||
const resource = await createProjectPickerFixture();
|
|
||||||
const { fixture } = resource;
|
|
||||||
let projectId: string | null = null;
|
|
||||||
try {
|
|
||||||
await provide({
|
|
||||||
...fixture,
|
|
||||||
rememberProjectId: (openedProjectId) => {
|
|
||||||
projectId = openedProjectId;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
const client = await connectSeedClient();
|
|
||||||
try {
|
|
||||||
await removeProjectPickerFixture(client, fixture, projectId);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await resource.removeDirectory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
withWorkspace: async ({ page }, provide) => {
|
withWorkspace: async ({ page }, provide) => {
|
||||||
const handle = createWithWorkspace(page);
|
const handle = createWithWorkspace(page);
|
||||||
await provide(handle.withWorkspace);
|
await provide(handle.withWorkspace);
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export interface DesktopBridgeConfig {
|
|||||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||||
*/
|
*/
|
||||||
confirmShouldAccept?: boolean;
|
confirmShouldAccept?: boolean;
|
||||||
dialogOpenResult?: string | string[] | null;
|
|
||||||
editorTargets?: DesktopEditorTargetConfig[];
|
editorTargets?: DesktopEditorTargetConfig[];
|
||||||
editorRecordPath?: string;
|
editorRecordPath?: string;
|
||||||
}
|
}
|
||||||
@@ -97,7 +96,6 @@ export interface ConfirmDialogCall {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
|
||||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +157,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
dialog: {
|
dialog: {
|
||||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||||
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
|
||||||
};
|
};
|
||||||
getPendingOpenProject: () => Promise<string | null>;
|
getPendingOpenProject: () => Promise<string | null>;
|
||||||
events: { on: () => Promise<() => void> };
|
events: { on: () => Promise<() => void> };
|
||||||
@@ -252,10 +249,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
};
|
};
|
||||||
return cfg.confirmShouldAccept ?? false;
|
return cfg.confirmShouldAccept ?? false;
|
||||||
},
|
},
|
||||||
open: async (options?: Record<string, unknown>) => {
|
|
||||||
window.__capturedDialogOpenCalls.push(options);
|
|
||||||
return cfg.dialogOpenResult ?? null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
getPendingOpenProject: async () => null,
|
getPendingOpenProject: async () => null,
|
||||||
events: { on: async () => () => undefined },
|
events: { on: async () => () => undefined },
|
||||||
@@ -270,18 +263,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__capturedDialogOpenCalls = [];
|
|
||||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||||
}, config);
|
}, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForDirectoryDialog(
|
|
||||||
page: Page,
|
|
||||||
): Promise<Record<string, unknown> | undefined> {
|
|
||||||
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
|
|
||||||
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { expect, type Locator } from "@playwright/test";
|
|
||||||
|
|
||||||
interface TruncatedLabel {
|
|
||||||
text: string;
|
|
||||||
scrollWidth: number;
|
|
||||||
clientWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectNoTruncation(locator: Locator): Promise<void> {
|
|
||||||
await expect(locator.first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const truncated = await locator.evaluateAll((elements): TruncatedLabel[] => {
|
|
||||||
function isVisible(element: HTMLElement): boolean {
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
style.display !== "none" &&
|
|
||||||
style.visibility !== "hidden" &&
|
|
||||||
rect.width > 0 &&
|
|
||||||
rect.height > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function directText(element: HTMLElement): string {
|
|
||||||
return Array.from(element.childNodes)
|
|
||||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
||||||
.map((node) => node.textContent ?? "")
|
|
||||||
.join("")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const checked = new Set<HTMLElement>();
|
|
||||||
const failures: TruncatedLabel[] = [];
|
|
||||||
|
|
||||||
for (const root of elements) {
|
|
||||||
if (!(root instanceof HTMLElement) || !isVisible(root)) continue;
|
|
||||||
const candidates = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (checked.has(candidate) || !isVisible(candidate) || candidate.clientWidth <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
checked.add(candidate);
|
|
||||||
|
|
||||||
const text = directText(candidate);
|
|
||||||
if (!text) continue;
|
|
||||||
if (candidate.scrollWidth <= candidate.clientWidth) continue;
|
|
||||||
|
|
||||||
failures.push({
|
|
||||||
text,
|
|
||||||
scrollWidth: candidate.scrollWidth,
|
|
||||||
clientWidth: candidate.clientWidth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return failures;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(truncated, "option labels should not be horizontally truncated").toEqual([]);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { SeedDaemonClient } from "./seed-client";
|
|
||||||
|
|
||||||
export interface ProjectPickerFixture {
|
|
||||||
projectPath: string;
|
|
||||||
projectName: string;
|
|
||||||
fuzzyQuery: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectPickerFixtureResource {
|
|
||||||
fixture: ProjectPickerFixture;
|
|
||||||
removeDirectory: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
|
|
||||||
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
|
|
||||||
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
|
|
||||||
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
|
|
||||||
await mkdir(projectPath, { recursive: true });
|
|
||||||
|
|
||||||
return {
|
|
||||||
fixture: {
|
|
||||||
projectPath,
|
|
||||||
projectName: path.basename(projectPath),
|
|
||||||
fuzzyQuery: `psodfzt${nonce}`,
|
|
||||||
},
|
|
||||||
removeDirectory: () => rm(root, { recursive: true, force: true }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeProjectPickerFixture(
|
|
||||||
client: SeedDaemonClient,
|
|
||||||
fixture: ProjectPickerFixture,
|
|
||||||
knownProjectId: string | null = null,
|
|
||||||
): Promise<void> {
|
|
||||||
let projectId = knownProjectId;
|
|
||||||
if (!projectId) {
|
|
||||||
const lookup = await client.addProject(fixture.projectPath);
|
|
||||||
projectId = lookup.project?.projectId ?? null;
|
|
||||||
if (!projectId) {
|
|
||||||
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.removeProject(projectId);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
|
|
||||||
const projectRow = page
|
|
||||||
.locator('[data-testid^="sidebar-project-row-"]')
|
|
||||||
.filter({ hasText: projectName })
|
|
||||||
.first();
|
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const testId = await projectRow.getAttribute("data-testid");
|
|
||||||
expect(testId).not.toBeNull();
|
|
||||||
return testId!.replace("sidebar-project-row-", "");
|
|
||||||
}
|
|
||||||
@@ -5,29 +5,6 @@ import type { WebSocketRoute } from "@playwright/test";
|
|||||||
import { gotoAppShell, openSettings } from "./app";
|
import { gotoAppShell, openSettings } from "./app";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
|
||||||
|
|
||||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
|
||||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
|
||||||
const envelope = parseWebSocketJson(message);
|
|
||||||
if (!envelope || typeof envelope !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
|
||||||
if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return maybeEnvelope.message as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
export async function openProjects(page: Page): Promise<void> {
|
export async function openProjects(page: Page): Promise<void> {
|
||||||
@@ -194,39 +171,33 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
|||||||
|
|
||||||
// --- WebSocket helpers ---
|
// --- WebSocket helpers ---
|
||||||
|
|
||||||
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||||
// until the test explicitly allows recovery. Closing the transport leaves the
|
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||||
// client-side RPC pending across reconnects, so this injects the same correlated
|
// connections pass through so the Reload action can succeed.
|
||||||
// rpc_error shape the daemon emits for failed async session requests.
|
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||||
export async function installReadTransportFailure(
|
let armed = true;
|
||||||
page: Page,
|
|
||||||
): Promise<{ allowRecovery: () => void }> {
|
|
||||||
let shouldFailReads = true;
|
|
||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
const sessionMessage = getSessionMessage(message);
|
if (armed && typeof message === "string") {
|
||||||
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
try {
|
||||||
const requestId = sessionMessage.requestId;
|
const envelope = JSON.parse(message) as {
|
||||||
if (typeof requestId === "string") {
|
type?: string;
|
||||||
ws.send(
|
message?: { type?: string };
|
||||||
JSON.stringify({
|
};
|
||||||
type: "session",
|
if (
|
||||||
message: {
|
envelope.type === "session" &&
|
||||||
type: "rpc_error",
|
envelope.message?.type === "read_project_config_request"
|
||||||
payload: {
|
) {
|
||||||
requestId,
|
armed = false;
|
||||||
requestType: "read_project_config_request",
|
void ws.close({ code: 1001 });
|
||||||
error: "Test read transport failure.",
|
return;
|
||||||
code: "transport",
|
}
|
||||||
},
|
} catch {
|
||||||
},
|
// binary or malformed frame — pass through
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
server.send(message);
|
server.send(message);
|
||||||
@@ -243,12 +214,6 @@ export async function installReadTransportFailure(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
allowRecovery() {
|
|
||||||
shouldFailReads = false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||||
|
|||||||
@@ -10,51 +10,50 @@ export async function expectCurrentQuestion(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const card = page.getByTestId("question-form-card").first();
|
const card = page.getByTestId("question-form-card").first();
|
||||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||||
// Nav tabs only render for multi-question cards (hidden for a lone question).
|
await expect(
|
||||||
if (input.total > 1) {
|
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||||
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
|
).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options render as radios (single-select) or checkboxes (multi-select), so match
|
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||||
// either role by accessible name.
|
await page
|
||||||
function questionOption(page: Page, option: string) {
|
|
||||||
const card = page.getByTestId("question-form-card").first();
|
|
||||||
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The multi-question nav renders as a tablist; each question is a tab.
|
|
||||||
function questionNavTab(page: Page, input: { index: number; total: number }) {
|
|
||||||
return page
|
|
||||||
.getByTestId("question-form-card")
|
.getByTestId("question-form-card")
|
||||||
.first()
|
.first()
|
||||||
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
|
.getByRole("button", { name: option })
|
||||||
}
|
.click();
|
||||||
|
|
||||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
|
||||||
await questionOption(page, option).click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
await expect(
|
||||||
|
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openQuestion(
|
export async function openQuestion(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { index: number; total: number },
|
input: { index: number; total: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await questionNavTab(page, input).click();
|
await page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||||
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectQuestionNavigationEnabled(
|
export async function expectQuestionNavigationEnabled(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { index: number; total: number },
|
input: { index: number; total: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await expect(questionNavTab(page, input)).toBeEnabled();
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||||
|
).toBeEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fillQuestionAnswer(
|
export async function fillQuestionAnswer(
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
import { type Page } from "@playwright/test";
|
|
||||||
import { buildSeededHost } from "./daemon-registry";
|
|
||||||
import { wsRoutePatternForPort } from "./daemon-port";
|
|
||||||
import { type SeededWorkspace } from "./seed-client";
|
|
||||||
|
|
||||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
|
||||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
|
||||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
|
||||||
export const FAKE_HOST_MODEL_ID = "fake-host-model";
|
|
||||||
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
|
||||||
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
|
||||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
|
||||||
|
|
||||||
export interface FakeScheduleHostWorkspace {
|
|
||||||
serverId: string;
|
|
||||||
projectId: string;
|
|
||||||
projectDisplayName: string;
|
|
||||||
workspace: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FakeScheduleSummary {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
prompt: string;
|
|
||||||
cadence: { type: "cron"; expression: string };
|
|
||||||
target: {
|
|
||||||
type: "new-agent";
|
|
||||||
config: {
|
|
||||||
provider: "mock";
|
|
||||||
cwd: string;
|
|
||||||
model: string;
|
|
||||||
modeId: string;
|
|
||||||
title?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
status: "active";
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
nextRunAt: string | null;
|
|
||||||
lastRunAt: string | null;
|
|
||||||
pausedAt: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
maxRuns: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJson(message: WebSocketMessage): unknown {
|
|
||||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
|
|
||||||
return JSON.stringify({
|
|
||||||
type: "session",
|
|
||||||
message: {
|
|
||||||
type,
|
|
||||||
payload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFakeProviderEntries(nowIso: string) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
provider: "mock",
|
|
||||||
label: "Mock",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
fetchedAt: nowIso,
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "mock",
|
|
||||||
id: FAKE_HOST_MODEL_ID,
|
|
||||||
label: FAKE_HOST_MODEL_LABEL,
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
modes: [{ id: "load-test", label: "Load test" }],
|
|
||||||
defaultModeId: "load-test",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
|
|
||||||
const parsed = parseJson(message);
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envelope = parsed as { type?: string; message?: SessionRequest };
|
|
||||||
if (envelope.type !== "session" || !envelope.message) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return envelope.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequestId(request: SessionRequest): string {
|
|
||||||
return typeof request.requestId === "string" ? request.requestId : "fake-request";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildFakeScheduleHostWorkspace(
|
|
||||||
workspace: SeededWorkspace,
|
|
||||||
): Promise<FakeScheduleHostWorkspace> {
|
|
||||||
const workspaceList = await workspace.client.fetchWorkspaces({
|
|
||||||
filter: { projectId: workspace.projectId },
|
|
||||||
});
|
|
||||||
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
|
|
||||||
if (!baseWorkspace) {
|
|
||||||
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = `${workspace.projectId}-fake-host`;
|
|
||||||
const cwd = `${workspace.repoPath}-fake-host`;
|
|
||||||
return {
|
|
||||||
serverId: "schedule-fake-host",
|
|
||||||
projectId,
|
|
||||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
|
||||||
workspace: {
|
|
||||||
...baseWorkspace,
|
|
||||||
id: `${baseWorkspace.id}-fake-host`,
|
|
||||||
projectId,
|
|
||||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
|
||||||
projectRootPath: cwd,
|
|
||||||
workspaceDirectory: cwd,
|
|
||||||
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
|
||||||
project: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installFakeScheduleHost(input: {
|
|
||||||
page: Page;
|
|
||||||
port: string;
|
|
||||||
serverId: string;
|
|
||||||
workspace: Record<string, unknown>;
|
|
||||||
schedules?: FakeScheduleSummary[];
|
|
||||||
}): Promise<void> {
|
|
||||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
|
||||||
ws.onMessage((message) => {
|
|
||||||
const parsed = parseJson(message);
|
|
||||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("status", {
|
|
||||||
status: "server_info",
|
|
||||||
serverId: input.serverId,
|
|
||||||
hostname: "fake-schedule-host",
|
|
||||||
version: "0.0.0-e2e",
|
|
||||||
features: {
|
|
||||||
providersSnapshot: true,
|
|
||||||
workspaceMultiplicity: true,
|
|
||||||
projectAdd: true,
|
|
||||||
projectRemove: true,
|
|
||||||
worktreeRestore: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
|
|
||||||
ws.send(JSON.stringify({ type: "pong" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = readSessionRequest(message);
|
|
||||||
if (!request) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = getRequestId(request);
|
|
||||||
const now = Date.now();
|
|
||||||
const nowIso = new Date(now).toISOString();
|
|
||||||
switch (request.type) {
|
|
||||||
case "ping":
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("pong", {
|
|
||||||
requestId,
|
|
||||||
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
|
|
||||||
serverReceivedAt: now,
|
|
||||||
serverSentAt: now,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case "fetch_workspaces_request":
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("fetch_workspaces_response", {
|
|
||||||
requestId,
|
|
||||||
entries: [input.workspace],
|
|
||||||
emptyProjects: [],
|
|
||||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case "fetch_agents_request":
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("fetch_agents_response", {
|
|
||||||
requestId,
|
|
||||||
entries: [],
|
|
||||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case "get_providers_snapshot_request":
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("get_providers_snapshot_response", {
|
|
||||||
requestId,
|
|
||||||
entries: buildFakeProviderEntries(nowIso),
|
|
||||||
generatedAt: nowIso,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case "refresh_providers_snapshot_request":
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("refresh_providers_snapshot_response", {
|
|
||||||
requestId,
|
|
||||||
acknowledged: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case "schedule/list":
|
|
||||||
ws.send(
|
|
||||||
buildSessionMessage("schedule/list/response", {
|
|
||||||
requestId,
|
|
||||||
schedules: input.schedules ?? [],
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addFakeScheduleHostAndReload(input: {
|
|
||||||
page: Page;
|
|
||||||
serverId: string;
|
|
||||||
label: string;
|
|
||||||
port: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const host = buildSeededHost({
|
|
||||||
serverId: input.serverId,
|
|
||||||
label: input.label,
|
|
||||||
endpoint: `127.0.0.1:${input.port}`,
|
|
||||||
nowIso: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await input.page.evaluate(
|
|
||||||
({ seededHost, keys }) => {
|
|
||||||
const nonce = localStorage.getItem(keys.nonce);
|
|
||||||
if (!nonce) {
|
|
||||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
|
||||||
}
|
|
||||||
const raw = localStorage.getItem(keys.registry);
|
|
||||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
|
||||||
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
|
|
||||||
nextRegistry.push(seededHost);
|
|
||||||
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
|
|
||||||
|
|
||||||
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
|
|
||||||
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
|
|
||||||
? JSON.parse(rawExtraHosts)
|
|
||||||
: [];
|
|
||||||
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
|
|
||||||
nextExtraHosts.push(seededHost);
|
|
||||||
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seededHost: host,
|
|
||||||
keys: {
|
|
||||||
registry: REGISTRY_KEY,
|
|
||||||
nonce: SEED_NONCE_KEY,
|
|
||||||
extraHosts: EXTRA_HOSTS_KEY,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await input.page.reload();
|
|
||||||
}
|
|
||||||
@@ -371,7 +371,6 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
|||||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
// Host group rows are now flat top-level sections (no drill-in).
|
// Host group rows are now flat top-level sections (no drill-in).
|
||||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { expect, type Locator } from "@playwright/test";
|
|
||||||
|
|
||||||
interface SettledOptions {
|
|
||||||
timeout?: number;
|
|
||||||
durationMs?: number;
|
|
||||||
intervalMs?: number;
|
|
||||||
heightTolerance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Sample {
|
|
||||||
text: string;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DURATION_MS = 1_500;
|
|
||||||
const DEFAULT_INTERVAL_MS = 100;
|
|
||||||
const DEFAULT_HEIGHT_TOLERANCE = 1;
|
|
||||||
|
|
||||||
async function sample(locator: Locator): Promise<Sample> {
|
|
||||||
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
|
|
||||||
if (!box) {
|
|
||||||
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
|
|
||||||
}
|
|
||||||
return { text: text ?? "", height: box.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
|
|
||||||
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
|
||||||
|
|
||||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
|
||||||
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
||||||
const samples: Sample[] = [];
|
|
||||||
const deadline = Date.now() + durationMs;
|
|
||||||
|
|
||||||
while (Date.now() <= deadline || samples.length < 2) {
|
|
||||||
samples.push(await sample(locator));
|
|
||||||
await locator.page().waitForTimeout(intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return samples;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertStableHeight(samples: Sample[], tolerance: number): void {
|
|
||||||
const heights = samples.map((entry) => entry.height);
|
|
||||||
const minHeight = Math.min(...heights);
|
|
||||||
const maxHeight = Math.max(...heights);
|
|
||||||
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
|
|
||||||
tolerance,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSettledText(samples: Sample[]): void {
|
|
||||||
let stableText: string | null = null;
|
|
||||||
let previousText: string | null = null;
|
|
||||||
|
|
||||||
for (const entry of samples) {
|
|
||||||
if (stableText !== null && entry.text !== stableText) {
|
|
||||||
throw new Error(
|
|
||||||
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousText === entry.text) {
|
|
||||||
stableText = entry.text;
|
|
||||||
}
|
|
||||||
previousText = entry.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stableText === null) {
|
|
||||||
throw new Error("Text did not reach a stable value during the settledness window.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
|
|
||||||
const samples = await collectSamples(locator, options);
|
|
||||||
assertSettledText(samples);
|
|
||||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectStableHeight(
|
|
||||||
locator: Locator,
|
|
||||||
options?: SettledOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
const samples = await collectSamples(locator, options);
|
|
||||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
selectNewWorkspaceProject,
|
selectNewWorkspaceProject,
|
||||||
submitNewWorkspacePrompt,
|
submitNewWorkspacePrompt,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
|
||||||
import { escapeRegex } from "./helpers/regex";
|
import { escapeRegex } from "./helpers/regex";
|
||||||
import { seedWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
@@ -92,10 +91,6 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||||
await modeControl.click();
|
await modeControl.click();
|
||||||
|
|
||||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectNoTruncation(popup);
|
|
||||||
|
|
||||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill(label);
|
await searchInput.fill(label);
|
||||||
@@ -110,19 +105,6 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectThinkingOptionsFit(page: Page): Promise<void> {
|
|
||||||
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
|
|
||||||
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
|
|
||||||
await thinkingControl.click();
|
|
||||||
|
|
||||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectNoTruncation(popup);
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||||
}> {
|
}> {
|
||||||
@@ -176,7 +158,6 @@ test.describe("New workspace Codex mode preferences", () => {
|
|||||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expectThinkingOptionsFit(page);
|
|
||||||
await selectMode(page, "Full access");
|
await selectMode(page, "Full access");
|
||||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,14 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
|
|||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
// Model B entry points into the New Workspace screen. The per-project
|
||||||
// the global button (universal) and each project's per-row New workspace icon
|
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||||
// (preselects that project) — shown for git projects and for non-git projects on
|
// button (universal) and each git project's own new-worktree icon (preselects
|
||||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
// that project). These specs prove the global entry opens the screen, the
|
||||||
// screen, the project icon preselects the right project across the reused 'new'
|
// project icon preselects the right project across the reused 'new' screen, and
|
||||||
// screen, and non-git projects never offer the worktree Isolation control.
|
// non-git projects never offer the worktree Isolation control.
|
||||||
|
|
||||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||||
@@ -106,54 +105,6 @@ test.describe("New workspace entry points", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const otherProject: SeededWorkspace = await seedWorkspace({
|
|
||||||
repoPrefix: "aa-new-workspace-archive-other-",
|
|
||||||
});
|
|
||||||
const rememberedProject: SeededWorkspace = await seedWorkspace({
|
|
||||||
repoPrefix: "zz-new-workspace-archive-remembered-",
|
|
||||||
});
|
|
||||||
const serverId = getServerId();
|
|
||||||
const draftText = "keep this new workspace draft";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await seedSavedSettingsHosts(page, [
|
|
||||||
{
|
|
||||||
serverId,
|
|
||||||
label: "localhost",
|
|
||||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page
|
|
||||||
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
||||||
|
|
||||||
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
|
|
||||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
|
||||||
await composer.fill(draftText);
|
|
||||||
await expect(composer).toHaveValue(draftText);
|
|
||||||
|
|
||||||
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
|
|
||||||
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
|
||||||
await expect(composer).toHaveValue(draftText);
|
|
||||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
|
||||||
} finally {
|
|
||||||
await otherProject.cleanup();
|
|
||||||
await rememberedProject.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -215,7 +166,7 @@ test.describe("New workspace entry points", () => {
|
|||||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Open New Workspace for the non-git project via the global button, then
|
// Open New Workspace for the non-git project via the global button, then
|
||||||
// select it in the picker (the per-row icon would preselect it too).
|
// select it in the picker (its row has no new-worktree icon).
|
||||||
await openGlobalNewWorkspaceComposer(page);
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
openStartingRefPicker,
|
openStartingRefPicker,
|
||||||
selectBranchInPicker,
|
selectBranchInPicker,
|
||||||
|
selectWorkspaceIsolation,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
@@ -63,11 +63,7 @@ test.describe("New workspace isolation memory", () => {
|
|||||||
projectDisplayName: openedProject.projectDisplayName,
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectWorkspaceIsolationSelected(page, "local");
|
await expectWorkspaceIsolationSelected(page, "local");
|
||||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectNoTruncation(isolationPopup);
|
|
||||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
|
||||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||||
|
|
||||||
await openStartingRefPicker(page);
|
await openStartingRefPicker(page);
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { gotoAppShell } from "./helpers/app";
|
|
||||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
|
||||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
|
|
||||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
|
|
||||||
|
|
||||||
test("Browse opens the folder selected by the desktop dialog", async ({
|
|
||||||
page,
|
|
||||||
projectPickerFixture,
|
|
||||||
}) => {
|
|
||||||
await injectDesktopBridge(page, {
|
|
||||||
serverId: getServerId(),
|
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
dialogOpenResult: projectPickerFixture.projectPath,
|
|
||||||
});
|
|
||||||
await gotoAppShell(page);
|
|
||||||
|
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
|
||||||
const browse = page.getByRole("button", { name: "Browse…" });
|
|
||||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
|
||||||
await browse.click();
|
|
||||||
|
|
||||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
|
||||||
projectPickerFixture.rememberProjectId(projectId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Browse owns Enter without opening the active typed path", async ({
|
|
||||||
page,
|
|
||||||
projectPickerFixture,
|
|
||||||
}) => {
|
|
||||||
await injectDesktopBridge(page, {
|
|
||||||
serverId: getServerId(),
|
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
dialogOpenResult: null,
|
|
||||||
});
|
|
||||||
await gotoAppShell(page);
|
|
||||||
|
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
|
||||||
const input = page.getByTestId("project-picker-input");
|
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
|
||||||
await input.fill(projectPickerFixture.projectPath);
|
|
||||||
|
|
||||||
const browse = page.getByRole("button", { name: "Browse…" });
|
|
||||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
|
||||||
await browse.press("Enter");
|
|
||||||
|
|
||||||
const dialogOptions = await waitForDirectoryDialog(page);
|
|
||||||
expect(dialogOptions).toEqual({
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
await expect(input).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.locator('[data-testid^="sidebar-project-row-"]')
|
|
||||||
.filter({ hasText: projectPickerFixture.projectName }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@@ -136,7 +136,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
|||||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
|
|
||||||
const input = page.getByTestId("project-picker-input");
|
const input = page.getByPlaceholder("Type a directory path...");
|
||||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
await input.fill(projectPath);
|
await input.fill(projectPath);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
|||||||
page,
|
page,
|
||||||
editableProject,
|
editableProject,
|
||||||
}) => {
|
}) => {
|
||||||
// Reject read_project_config_request calls until the user clicks Reload.
|
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||||
// This keeps automatic reconnect refetches from racing past the callout.
|
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||||
const transportFailure = await installReadTransportFailure(page);
|
await installReadTransportFailure(page);
|
||||||
|
|
||||||
await openProjects(page);
|
await openProjects(page);
|
||||||
await navigateToProjectSettings(page, editableProject.name);
|
await navigateToProjectSettings(page, editableProject.name);
|
||||||
@@ -292,8 +292,7 @@ test.describe("Projects settings — error UX", () => {
|
|||||||
await expectProjectSettingsError(page, "transport");
|
await expectProjectSettingsError(page, "transport");
|
||||||
await expectProjectSettingsFormHidden(page);
|
await expectProjectSettingsFormHidden(page);
|
||||||
|
|
||||||
// Retry Reload until the refetch wins any in-flight error-state rendering.
|
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||||
transportFailure.allowRecovery();
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await clickReloadProjectSettings(page);
|
await clickReloadProjectSettings(page);
|
||||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { gotoAppShell } from "./helpers/app";
|
|
||||||
import {
|
|
||||||
addFakeScheduleHostAndReload,
|
|
||||||
buildFakeScheduleHostWorkspace,
|
|
||||||
FAKE_HOST_MODEL_ID,
|
|
||||||
FAKE_HOST_MODEL_LABEL,
|
|
||||||
installFakeScheduleHost,
|
|
||||||
} from "./helpers/schedule-fake-host";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
|
||||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
|
||||||
|
|
||||||
interface ScheduleSeedClient {
|
|
||||||
scheduleCreate(input: {
|
|
||||||
prompt: string;
|
|
||||||
name?: string;
|
|
||||||
cadence: { type: "cron"; expression: string };
|
|
||||||
target: {
|
|
||||||
type: "new-agent";
|
|
||||||
config: {
|
|
||||||
provider: "mock";
|
|
||||||
cwd: string;
|
|
||||||
model: string;
|
|
||||||
modeId: string;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
runOnCreate: boolean;
|
|
||||||
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
|
|
||||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
|
|
||||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const result = await client.scheduleCreate({
|
|
||||||
prompt: "Say hello from the scheduled agent.",
|
|
||||||
name,
|
|
||||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
|
||||||
target: {
|
|
||||||
type: "new-agent",
|
|
||||||
config: {
|
|
||||||
provider: "mock",
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
model: "ten-second-stream",
|
|
||||||
modeId: "load-test",
|
|
||||||
title: name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runOnCreate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.schedule) {
|
|
||||||
throw new Error(result.error ?? "Failed to seed schedule");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.schedule.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ignoreScheduleDeleteError(): void {}
|
|
||||||
|
|
||||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
|
||||||
await (workspace.client as unknown as ScheduleSeedClient)
|
|
||||||
.scheduleDelete({ id })
|
|
||||||
.catch(ignoreScheduleDeleteError);
|
|
||||||
}
|
|
||||||
|
|
||||||
type FakeScheduleHostSchedule = NonNullable<
|
|
||||||
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
|
|
||||||
>[number];
|
|
||||||
|
|
||||||
function buildFakeHostSchedule(input: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
cwd: string;
|
|
||||||
}): FakeScheduleHostSchedule {
|
|
||||||
const now = "2026-07-01T00:00:00.000Z";
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
prompt: "Run on the secondary host.",
|
|
||||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
|
||||||
target: {
|
|
||||||
type: "new-agent",
|
|
||||||
config: {
|
|
||||||
provider: "mock",
|
|
||||||
cwd: input.cwd,
|
|
||||||
model: FAKE_HOST_MODEL_ID,
|
|
||||||
modeId: "load-test",
|
|
||||||
title: input.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
nextRunAt: now,
|
|
||||||
lastRunAt: null,
|
|
||||||
pausedAt: null,
|
|
||||||
expiresAt: null,
|
|
||||||
maxRuns: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Schedules", () => {
|
|
||||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
for (const cleanup of cleanupTasks.toReversed()) {
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
cleanupTasks.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
|
||||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
|
||||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
|
||||||
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
|
||||||
|
|
||||||
await row.click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
|
||||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
|
||||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(hostTrigger).toBeDisabled();
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectSettled(modeTrigger);
|
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
|
||||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
|
||||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
|
||||||
const scheduleId = "fake-host-schedule";
|
|
||||||
const scheduleName = "Secondary host schedule";
|
|
||||||
|
|
||||||
await installFakeScheduleHost({
|
|
||||||
page,
|
|
||||||
port: fakePort,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
workspace: fakeHost.workspace,
|
|
||||||
schedules: [
|
|
||||||
buildFakeHostSchedule({
|
|
||||||
id: scheduleId,
|
|
||||||
name: scheduleName,
|
|
||||||
cwd: String(fakeHost.workspace.workspaceDirectory),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await addFakeScheduleHostAndReload({
|
|
||||||
page,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
label: "Fake host",
|
|
||||||
port: fakePort,
|
|
||||||
});
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
|
||||||
|
|
||||||
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
|
|
||||||
await expect(hostTrigger).toBeDisabled();
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
|
|
||||||
await expectSettled(modeTrigger);
|
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const scheduleName = `Pristine create ${Date.now()}`;
|
|
||||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
|
||||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
|
||||||
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
|
||||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
|
|
||||||
await page.getByTestId("schedules-new").click();
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
await expect(projectTrigger).toContainText(/select project/i);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
|
||||||
import { gotoAppShell } from "./helpers/app";
|
|
||||||
import {
|
|
||||||
addFakeScheduleHostAndReload,
|
|
||||||
buildFakeScheduleHostWorkspace,
|
|
||||||
installFakeScheduleHost,
|
|
||||||
} from "./helpers/schedule-fake-host";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
import { escapeRegex } from "./helpers/regex";
|
|
||||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
|
||||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
|
||||||
|
|
||||||
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
|
|
||||||
|
|
||||||
interface ScheduleListItem {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
cadence?: { type: "cron"; expression: string };
|
|
||||||
target: {
|
|
||||||
type: string;
|
|
||||||
config?: {
|
|
||||||
cwd?: string;
|
|
||||||
archiveOnFinish?: boolean;
|
|
||||||
isolation?: "local" | "worktree";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleSeedClient {
|
|
||||||
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
|
|
||||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: /select model/i }).click();
|
|
||||||
const popup = page.getByTestId("combobox-desktop-container");
|
|
||||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
|
||||||
const searchInput = page.getByTestId("model-search-input").first();
|
|
||||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
|
||||||
await searchInput.fill(label);
|
|
||||||
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
|
||||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
|
||||||
await option.click();
|
|
||||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
|
|
||||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const list = await client.scheduleList();
|
|
||||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
|
||||||
if (schedule) {
|
|
||||||
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectScheduleCreatedForProject(input: {
|
|
||||||
workspace: SeededWorkspace;
|
|
||||||
name: string;
|
|
||||||
cadenceExpression: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const list = await client.scheduleList();
|
|
||||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
|
||||||
expect(schedule).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: input.name,
|
|
||||||
cadence: expect.objectContaining({
|
|
||||||
type: "cron",
|
|
||||||
expression: input.cadenceExpression,
|
|
||||||
}),
|
|
||||||
target: expect.objectContaining({
|
|
||||||
type: "new-agent",
|
|
||||||
config: expect.objectContaining({
|
|
||||||
cwd: input.workspace.repoPath,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectScheduleKnobs(input: {
|
|
||||||
workspace: SeededWorkspace;
|
|
||||||
name: string;
|
|
||||||
archiveOnFinish: boolean;
|
|
||||||
isolation: "local" | "worktree";
|
|
||||||
}): Promise<void> {
|
|
||||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const list = await client.scheduleList();
|
|
||||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
|
||||||
expect(schedule).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: input.name,
|
|
||||||
target: expect.objectContaining({
|
|
||||||
type: "new-agent",
|
|
||||||
config: expect.objectContaining({
|
|
||||||
archiveOnFinish: input.archiveOnFinish,
|
|
||||||
isolation: input.isolation,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openNewScheduleSheet(page: Page): Promise<void> {
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bottomSheetBackdrop(page: Page) {
|
|
||||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dismissScheduleSheetWithBackdrop(page: Page): Promise<void> {
|
|
||||||
const backdrop = bottomSheetBackdrop(page);
|
|
||||||
await expect(backdrop).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(async () => {
|
|
||||||
const box = await backdrop.boundingBox();
|
|
||||||
if (box) {
|
|
||||||
await page.mouse.click(box.x + box.width / 2, box.y + 24);
|
|
||||||
}
|
|
||||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
|
||||||
}).toPass({ timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectScheduleSheetClosedAndStable(page: Page): Promise<void> {
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await expect(formSheet).toHaveCount(0, { timeout: 1_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findScheduleIdByName(workspace: SeededWorkspace, name: string): Promise<string> {
|
|
||||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const list = await client.scheduleList();
|
|
||||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
|
||||||
if (!schedule) {
|
|
||||||
throw new Error(`Expected schedule named ${name} to exist`);
|
|
||||||
}
|
|
||||||
return schedule.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Schedules project target", () => {
|
|
||||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
for (const cleanup of cleanupTasks.toReversed()) {
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
cleanupTasks.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dismisses the new schedule sheet without reopening", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-sheet-dismiss-", git: false });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await page.setViewportSize(MOBILE_SHEET_VIEWPORT);
|
|
||||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await openNewScheduleSheet(page);
|
|
||||||
await dismissScheduleSheetWithBackdrop(page);
|
|
||||||
await expectScheduleSheetClosedAndStable(page);
|
|
||||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await openNewScheduleSheet(page);
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
|
||||||
await expectScheduleSheetClosedAndStable(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const scheduleName = `Project schedule ${Date.now()}`;
|
|
||||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Schedules" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/schedules$/);
|
|
||||||
await expect(page).not.toHaveURL(/\/h\//);
|
|
||||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
|
||||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
|
||||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
await expect(modelTrigger).toContainText("Ten second stream");
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
|
||||||
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
|
||||||
await page.getByTestId("schedule-cadence-preset-daily-9").click();
|
|
||||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
|
||||||
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
|
|
||||||
|
|
||||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
|
||||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
|
||||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expectScheduleCreatedForProject({
|
|
||||||
workspace,
|
|
||||||
name: scheduleName,
|
|
||||||
cadenceExpression: "0 9 * * *",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
|
||||||
await installFakeScheduleHost({
|
|
||||||
page,
|
|
||||||
port: fakePort,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
workspace: fakeHost.workspace,
|
|
||||||
});
|
|
||||||
await addFakeScheduleHostAndReload({
|
|
||||||
page,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
label: "Fake host",
|
|
||||||
port: fakePort,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
|
|
||||||
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await hostFilterTrigger.click();
|
|
||||||
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectNoTruncation(hostFilterPopup);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clears the selected project and model when the host changes", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const serverId = getServerId();
|
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
|
||||||
|
|
||||||
await installFakeScheduleHost({
|
|
||||||
page,
|
|
||||||
port: fakePort,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
workspace: fakeHost.workspace,
|
|
||||||
});
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await addFakeScheduleHostAndReload({
|
|
||||||
page,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
label: "Fake host",
|
|
||||||
port: fakePort,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
|
|
||||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
|
||||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
|
||||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(projectTrigger).toHaveCount(0);
|
|
||||||
await expect(modelTrigger).toHaveCount(0);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
await hostTrigger.click();
|
|
||||||
await page.getByTestId(`schedule-host-option-${serverId}`).click();
|
|
||||||
await expect(projectTrigger).toBeVisible();
|
|
||||||
await expect(modelTrigger).toHaveCount(0);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
|
|
||||||
await projectTrigger.click();
|
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
|
||||||
await expect(modelTrigger).toContainText("Ten second stream");
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toBeVisible();
|
|
||||||
await expectSettled(modeTrigger);
|
|
||||||
|
|
||||||
await hostTrigger.click();
|
|
||||||
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
|
|
||||||
await expect(hostTrigger).toContainText("Fake host");
|
|
||||||
await expect(projectTrigger).toContainText(/select project/i);
|
|
||||||
await expect(modelTrigger).toHaveCount(0);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
|
|
||||||
await projectTrigger.click();
|
|
||||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
|
||||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
|
||||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(modelTrigger).toContainText(/select model/i);
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
|
||||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
|
||||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const scheduleName = `Knob schedule ${Date.now()}`;
|
|
||||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
|
||||||
await expect(
|
|
||||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await page.getByTestId("schedule-isolation-trigger").click();
|
|
||||||
await page.getByTestId("schedule-isolation-worktree").click();
|
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
|
||||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
|
||||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
|
||||||
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
|
|
||||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
|
||||||
await page.getByTestId("schedule-cadence-preset-every-hour").click();
|
|
||||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expectScheduleKnobs({
|
|
||||||
workspace,
|
|
||||||
name: scheduleName,
|
|
||||||
archiveOnFinish: false,
|
|
||||||
isolation: "worktree",
|
|
||||||
});
|
|
||||||
|
|
||||||
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
|
|
||||||
await page.getByTestId(`schedule-row-${scheduleId}`).click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
|
||||||
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
|
|
||||||
"aria-checked",
|
|
||||||
"false",
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
|
||||||
).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("schedule-isolation-trigger").click();
|
|
||||||
await page.getByTestId("schedule-isolation-local").click();
|
|
||||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
|
||||||
await page.getByRole("button", { name: "Save changes" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expectScheduleKnobs({
|
|
||||||
workspace,
|
|
||||||
name: scheduleName,
|
|
||||||
archiveOnFinish: true,
|
|
||||||
isolation: "local",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
|
|||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await openHostSection(page, serverId, "host");
|
await openHostSection(page, serverId, "host");
|
||||||
await expectSettingsHeader(page, "Overview");
|
await expectSettingsHeader(page, "Host");
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
|||||||
test.describe("Model B sidebar shape", () => {
|
test.describe("Model B sidebar shape", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
|
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
||||||
@@ -62,17 +62,14 @@ test.describe("Model B sidebar shape", () => {
|
|||||||
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
// The per-project "+ New workspace" row is gone. The git project keeps a
|
||||||
// git project can branch off a worktree, and the non-git project can add
|
// per-row new-worktree icon (revealed on hover); the non-git project has
|
||||||
// another workspace because the host supports workspaceMultiplicity.
|
// none, since worktree creation needs a git checkout.
|
||||||
await projectRow(page, gitProject.projectId).hover();
|
await projectRow(page, gitProject.projectId).hover();
|
||||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await projectRow(page, nonGitProject.projectId).hover();
|
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
|
||||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The global new-workspace button is the universal entry — present for both
|
// The global new-workspace button is the universal entry — present for both
|
||||||
// kinds regardless of their per-row affordance.
|
// kinds regardless of their per-row affordance.
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
|
await page.goto(
|
||||||
|
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||||
|
);
|
||||||
|
|
||||||
await expectHostConnectingOrOffline(page);
|
await expectHostConnectingOrOffline(page);
|
||||||
await expectMenuButtonVisible(page);
|
await expectMenuButtonVisible(page);
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
|||||||
|
|
||||||
const config = getDefaultConfig(projectRoot);
|
const config = getDefaultConfig(projectRoot);
|
||||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||||
|
|
||||||
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
|
|
||||||
// crawler behavior depends on the host Watchman build/capabilities, while the
|
|
||||||
// node crawler is the path used when Watchman is absent.
|
|
||||||
config.resolver.useWatchman = false;
|
|
||||||
|
|
||||||
const escapedAppSrcRoot = appSrcRoot
|
const escapedAppSrcRoot = appSrcRoot
|
||||||
.split(path.sep)
|
.split(path.sep)
|
||||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.105",
|
"version": "0.1.102",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:browser": "vitest run --project browser",
|
"test:browser": "vitest run --project browser",
|
||||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
|
||||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ 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";
|
||||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||||
import { useSettings } from "@/hooks/use-settings";
|
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||||
@@ -319,7 +318,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
|
||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
@@ -635,12 +633,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
args={item.text}
|
args={item.text}
|
||||||
status={item.status === "ready" ? "completed" : "executing"}
|
status={item.status === "ready" ? "completed" : "executing"}
|
||||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||||
defaultExpanded={autoExpandReasoning}
|
|
||||||
forceInline={autoExpandReasoning}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[autoExpandReasoning, setInlineDetailsExpanded],
|
[setInlineDetailsExpanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderToolCallItem = useCallback(
|
const renderToolCallItem = useCallback(
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
import {
|
||||||
|
Stack,
|
||||||
|
useGlobalSearchParams,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -17,8 +23,9 @@ import {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
@@ -27,19 +34,25 @@ import { DownloadToast } from "@/components/download-toast";
|
|||||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
|
||||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
import {
|
||||||
|
HorizontalScrollProvider,
|
||||||
|
useHorizontalScrollOptional,
|
||||||
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
import {
|
||||||
|
SidebarAnimationProvider,
|
||||||
|
useSidebarAnimation,
|
||||||
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
@@ -68,15 +81,12 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
|
|||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
|
||||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
|
||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
|
||||||
useHostRegistryLoaded,
|
useHostRegistryLoaded,
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -88,7 +98,13 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
|
import {
|
||||||
|
buildOpenProjectRoute,
|
||||||
|
parseHostAgentRouteFromPathname,
|
||||||
|
parseServerIdFromPathname,
|
||||||
|
parseWorkspaceOpenIntent,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import {
|
import {
|
||||||
@@ -117,13 +133,14 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
const target = resolveNotificationTarget(data);
|
const target = resolveNotificationTarget(data);
|
||||||
const serverId = target.serverId;
|
const serverId = target.serverId;
|
||||||
const agentId = target.agentId;
|
const agentId = target.agentId;
|
||||||
if (serverId && agentId) {
|
if (serverId && agentId) {
|
||||||
navigateToAgent({ serverId, agentId, pin: true });
|
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,9 +313,6 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
|||||||
if (!shouldUseDesktopDaemon()) {
|
if (!shouldUseDesktopDaemon()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (hasConfiguredLocalDaemonOverride()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const settings = await loadDesktopSettings();
|
const settings = await loadDesktopSettings();
|
||||||
return settings.daemon.manageBuiltInDaemon;
|
return settings.daemon.manageBuiltInDaemon;
|
||||||
}
|
}
|
||||||
@@ -387,16 +401,22 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||||
const flexStyle = { flex: 1 } as const;
|
const flexStyle = { flex: 1 } as const;
|
||||||
|
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||||
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||||
|
|
||||||
interface AppContainerProps {
|
interface AppContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
selectedAgentId?: string;
|
||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||||
|
|
||||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
function AppContainer({
|
||||||
|
children,
|
||||||
|
selectedAgentId,
|
||||||
|
chromeEnabled: chromeEnabledOverride,
|
||||||
|
}: AppContainerProps) {
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
@@ -453,22 +473,26 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
|
|
||||||
const workspaceChrome = (
|
const workspaceChrome = (
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
|
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||||
|
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||||
|
)}
|
||||||
{isCompactLayout && chromeEnabled ? (
|
{isCompactLayout && chromeEnabled ? (
|
||||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
<ExplorerSidebarAnimationProvider>
|
||||||
<View style={flexStyle}>{children}</View>
|
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||||
</CompactExplorerSidebarHost>
|
<View style={flexStyle}>{children}</View>
|
||||||
|
</CompactExplorerSidebarHost>
|
||||||
|
</ExplorerSidebarAnimationProvider>
|
||||||
) : (
|
) : (
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const surface = (
|
const content = (
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
{workspaceChrome}
|
{workspaceChrome}
|
||||||
<FloatingPanelPortalHost />
|
<FloatingPanelPortalHost />
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar />}
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<RosettaCalloutSource />
|
<RosettaCalloutSource />
|
||||||
<UpdateCalloutSource />
|
<UpdateCalloutSource />
|
||||||
@@ -484,13 +508,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = isCompactLayout ? (
|
if (!isCompactLayout) {
|
||||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
return content;
|
||||||
) : (
|
}
|
||||||
surface
|
|
||||||
);
|
|
||||||
|
|
||||||
return <SidebarModelProvider>{content}</SidebarModelProvider>;
|
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileGestureWrapper({
|
function MobileGestureWrapper({
|
||||||
@@ -500,7 +522,127 @@ function MobileGestureWrapper({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
chromeEnabled: boolean;
|
chromeEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
|
const {
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
setOverlayPeek,
|
||||||
|
isGesturing,
|
||||||
|
mobilePanelState,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
} = useSidebarAnimation();
|
||||||
|
const touchStartX = useSharedValue(0);
|
||||||
|
const touchStartY = useSharedValue(0);
|
||||||
|
const openGestureEnabled = chromeEnabled;
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
showMobileAgentList();
|
||||||
|
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
|
const openGesture = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
|
.enabled(openGestureEnabled)
|
||||||
|
.manualActivation(true)
|
||||||
|
.failOffsetY([-10, 10])
|
||||||
|
.onTouchesDown((event) => {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (touch) {
|
||||||
|
touchStartX.value = touch.absoluteX;
|
||||||
|
touchStartY.value = touch.absoluteY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onTouchesMove((event, stateManager) => {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (!touch || event.numberOfTouches !== 1) return;
|
||||||
|
|
||||||
|
const deltaX = touch.absoluteX - touchStartX.value;
|
||||||
|
const deltaY = touch.absoluteY - touchStartY.value;
|
||||||
|
const absDeltaX = Math.abs(deltaX);
|
||||||
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaX <= -10) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaX > 15 && absDeltaX > absDeltaY) {
|
||||||
|
stateManager.activate();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onStart(() => {
|
||||||
|
isGesturing.value = true;
|
||||||
|
// The overlay is display:none while closed; reveal it for the drag.
|
||||||
|
runOnJS(setOverlayPeek)(true);
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||||
|
translateX.value = newTranslateX;
|
||||||
|
backdropOpacity.value = interpolate(
|
||||||
|
newTranslateX,
|
||||||
|
[-windowWidth, 0],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
isGesturing.value = false;
|
||||||
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
|
if (shouldOpen) {
|
||||||
|
animateToOpen();
|
||||||
|
runOnJS(handleGestureOpen)();
|
||||||
|
} else {
|
||||||
|
animateToClose();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
isGesturing.value = false;
|
||||||
|
runOnJS(setOverlayPeek)(false);
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
openGestureEnabled,
|
||||||
|
windowWidth,
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
mobilePanelState,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
setOverlayPeek,
|
||||||
|
handleGestureOpen,
|
||||||
|
isGesturing,
|
||||||
|
openGestureRef,
|
||||||
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
|
touchStartX,
|
||||||
|
touchStartY,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||||
@@ -736,6 +878,7 @@ function OpenProjectListener() {
|
|||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
@@ -746,10 +889,32 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
(pathname === "/open-project" ||
|
(pathname === "/open-project" ||
|
||||||
pathname === "/new" ||
|
pathname === "/new" ||
|
||||||
pathname === "/sessions" ||
|
pathname === "/sessions" ||
|
||||||
pathname === "/schedules" ||
|
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
// Parse selectedAgentKey directly from pathname
|
||||||
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||||
|
const selectedAgentKey = useMemo(() => {
|
||||||
|
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
|
||||||
|
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
|
||||||
|
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
|
||||||
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
|
if (workspaceServerId && openIntent?.kind === "agent") {
|
||||||
|
const agentId = openIntent.agentId.trim();
|
||||||
|
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = parseHostAgentRouteFromPathname(pathname);
|
||||||
|
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
||||||
|
}, [params.open, pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer
|
||||||
|
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
||||||
|
chromeEnabled={shouldShowAppChrome}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaviconStatusSync() {
|
function FaviconStatusSync() {
|
||||||
@@ -782,7 +947,6 @@ function RootStack() {
|
|||||||
<Stack.Screen name="new" />
|
<Stack.Screen name="new" />
|
||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
<Stack.Screen name="schedules" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
@@ -804,7 +968,7 @@ function WorkspaceRouteNavigationBridge() {
|
|||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<MobilePanelsProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<OpenProjectListener />
|
<OpenProjectListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
@@ -812,7 +976,7 @@ function AppShell() {
|
|||||||
<RootStack />
|
<RootStack />
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</MobilePanelsProvider>
|
</SidebarAnimationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,27 +993,31 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PortalProvider must stay inside normal app-wide context providers.
|
// PortalProvider must stay inside normal app-wide context providers here.
|
||||||
// `@gorhom/portal` renders portaled children at the host's location in the
|
// `@gorhom/portal` renders portaled children at the host's location in the
|
||||||
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
||||||
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
|
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
|
||||||
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
||||||
// context and need one shared provider for sibling sheets to stack.
|
// context and need one shared provider for sibling sheets to stack.
|
||||||
function RootProviders({ children }: { children: ReactNode }) {
|
function RootProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<QueryProvider>
|
||||||
<KeyboardProvider>
|
<I18nProvider>
|
||||||
<KeyboardShiftProvider>
|
<SafeAreaProvider>
|
||||||
<PortalProvider>
|
<KeyboardProvider>
|
||||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
<KeyboardShiftProvider>
|
||||||
</PortalProvider>
|
<PortalProvider>
|
||||||
</KeyboardShiftProvider>
|
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||||
</KeyboardProvider>
|
</PortalProvider>
|
||||||
</SafeAreaProvider>
|
</KeyboardShiftProvider>
|
||||||
|
</KeyboardProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</QueryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootAppTree() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={flexStyle}>
|
<GestureHandlerRootView style={flexStyle}>
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
@@ -863,18 +1031,6 @@ function RootAppTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
return (
|
|
||||||
<QueryProvider>
|
|
||||||
<I18nProvider>
|
|
||||||
<RootErrorBoundary>
|
|
||||||
<RootAppTree />
|
|
||||||
</RootErrorBoundary>
|
|
||||||
</I18nProvider>
|
|
||||||
</QueryProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutStyles = StyleSheet.create((theme) => ({
|
const layoutStyles = StyleSheet.create((theme) => ({
|
||||||
surfaceFill: {
|
surfaceFill: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
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";
|
||||||
@@ -17,6 +17,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
|
|
||||||
function HostAgentReadyRouteContent() {
|
function HostAgentReadyRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@@ -52,9 +53,10 @@ function HostAgentReadyRouteContent() {
|
|||||||
navigateToAgent({
|
navigateToAgent({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -94,6 +96,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,7 +114,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
|
||||||
|
|
||||||
export default function SchedulesRoute() {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<SchedulesScreen />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,7 @@
|
|||||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||||
import {
|
|
||||||
buildSettingsHostSectionRoute,
|
|
||||||
buildSettingsRoute,
|
|
||||||
isSettingsSectionSlug,
|
|
||||||
type SettingsSectionSlug,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
|
||||||
function SettingsDaemonRedirect() {
|
|
||||||
const hosts = useHosts();
|
|
||||||
const localDaemon = useLocalDaemonServerIdState();
|
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
|
||||||
|
|
||||||
if (localDaemon.status === "loading") {
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
localDaemon.status === "resolved" &&
|
|
||||||
localDaemon.serverId !== null &&
|
|
||||||
hosts.some((host) => host.serverId === localDaemon.serverId)
|
|
||||||
) {
|
|
||||||
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Redirect href={buildSettingsRoute()} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsSectionRoute() {
|
export default function SettingsSectionRoute() {
|
||||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||||
@@ -41,14 +10,5 @@ export default function SettingsSectionRoute() {
|
|||||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||||
|
|
||||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
|
||||||
if (rawSection === "daemon") {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<SettingsDaemonRedirect />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -42,14 +42,6 @@ export interface BrowserElementAttachment {
|
|||||||
} | null;
|
} | null;
|
||||||
parentChain: string[];
|
parentChain: string[];
|
||||||
children: string[];
|
children: string[];
|
||||||
/** Free-text review note the user wrote about this element, if any. */
|
|
||||||
comment?: string;
|
|
||||||
/**
|
|
||||||
* Cropped screenshot of the selected element, sent to the agent as an image
|
|
||||||
* alongside the textual element context. Persisted via the attachment store;
|
|
||||||
* referenced by id so the draft-store GC keeps it alive.
|
|
||||||
*/
|
|
||||||
screenshot?: AttachmentMetadata;
|
|
||||||
formatted: string;
|
formatted: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,611 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test } from "vitest";
|
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
||||||
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
|
||||||
import { mountBrowserAutomationHandler } from "./handler";
|
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
|
||||||
import {
|
|
||||||
buildWorkspaceTabPersistenceKey,
|
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "browser.automation.execute.request" }
|
|
||||||
>;
|
|
||||||
type BrowserAutomationExecuteResponse = Extract<
|
|
||||||
SessionInboundMessage,
|
|
||||||
{ type: "browser.automation.execute.response" }
|
|
||||||
>;
|
|
||||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
|
||||||
|
|
||||||
class FakeStateStorage implements StateStorage {
|
|
||||||
private readonly values = new Map<string, string>();
|
|
||||||
|
|
||||||
public getItem = (key: string): string | null => this.values.get(key) ?? null;
|
|
||||||
|
|
||||||
public setItem = (key: string, value: string): void => {
|
|
||||||
this.values.set(key, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
public removeItem = (key: string): void => {
|
|
||||||
this.values.delete(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
public clear(): void {
|
|
||||||
this.values.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeDaemonClient {
|
|
||||||
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
|
|
||||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
|
||||||
|
|
||||||
public on(
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
handler: (request: BrowserAutomationExecuteRequest) => void,
|
|
||||||
): () => void {
|
|
||||||
expect(type).toBe("browser.automation.execute.request");
|
|
||||||
this.handler = handler;
|
|
||||||
return () => {
|
|
||||||
if (this.handler === handler) {
|
|
||||||
this.handler = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
|
|
||||||
this.sentResponses.push(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
|
||||||
this.handler?.(nextRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
public payloadAt(index: number): BrowserAutomationResponsePayload {
|
|
||||||
const response = this.sentResponses[index];
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(`Missing browser automation response at index ${index}`);
|
|
||||||
}
|
|
||||||
return response.payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeBrowserBridge {
|
|
||||||
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
|
|
||||||
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
|
|
||||||
[];
|
|
||||||
public readonly unregisteredWorkspaceBrowsers: string[] = [];
|
|
||||||
public readonly clearedPartitions: string[] = [];
|
|
||||||
public readonly activeWorkspaceBrowsers: Array<{
|
|
||||||
browserId: string | null;
|
|
||||||
workspaceId: string;
|
|
||||||
}> = [];
|
|
||||||
public response: BrowserAutomationResponsePayload | null = null;
|
|
||||||
public thrownError: unknown = null;
|
|
||||||
|
|
||||||
public executeAutomationCommand = async (
|
|
||||||
request: BrowserAutomationExecuteRequest,
|
|
||||||
): Promise<BrowserAutomationResponsePayload> => {
|
|
||||||
this.executedRequests.push(request);
|
|
||||||
if (this.thrownError) {
|
|
||||||
throw this.thrownError;
|
|
||||||
}
|
|
||||||
return this.response ?? currentListTabsPayload(request.requestId);
|
|
||||||
};
|
|
||||||
|
|
||||||
public registerWorkspaceBrowser = async (input: {
|
|
||||||
browserId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}): Promise<void> => {
|
|
||||||
this.registeredWorkspaceBrowsers.push(input);
|
|
||||||
};
|
|
||||||
|
|
||||||
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
|
|
||||||
this.unregisteredWorkspaceBrowsers.push(browserId);
|
|
||||||
};
|
|
||||||
|
|
||||||
public clearPartition = async (browserId: string): Promise<void> => {
|
|
||||||
this.clearedPartitions.push(browserId);
|
|
||||||
};
|
|
||||||
|
|
||||||
public setWorkspaceActiveBrowser = async (input: {
|
|
||||||
browserId: string | null;
|
|
||||||
workspaceId: string;
|
|
||||||
}): Promise<void> => {
|
|
||||||
this.activeWorkspaceBrowsers.push(input);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeResidentBrowser {
|
|
||||||
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
|
|
||||||
|
|
||||||
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
|
|
||||||
this.ensuredWebviews.push(input);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class BrowserAutomationHandlerHarness {
|
|
||||||
public readonly client = new FakeDaemonClient();
|
|
||||||
public readonly browser = new FakeBrowserBridge();
|
|
||||||
public readonly resident = new FakeResidentBrowser();
|
|
||||||
private unsubscribe: (() => void) | null = null;
|
|
||||||
|
|
||||||
public mount(
|
|
||||||
input: {
|
|
||||||
serverId?: string;
|
|
||||||
host?: DesktopHostBridge | null;
|
|
||||||
registrationWaitTimeoutMs?: number;
|
|
||||||
registrationPollIntervalMs?: number;
|
|
||||||
} = {},
|
|
||||||
): void {
|
|
||||||
this.unsubscribe = mountBrowserAutomationHandler({
|
|
||||||
client: this.client,
|
|
||||||
...(input.serverId ? { serverId: input.serverId } : {}),
|
|
||||||
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
|
|
||||||
ensureResidentBrowserWebview: this.resident.ensure,
|
|
||||||
...(input.registrationWaitTimeoutMs !== undefined
|
|
||||||
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
|
|
||||||
: {}),
|
|
||||||
...(input.registrationPollIntervalMs !== undefined
|
|
||||||
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public unmount(): void {
|
|
||||||
this.unsubscribe?.();
|
|
||||||
this.unsubscribe = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public receive(request: BrowserAutomationExecuteRequest): void {
|
|
||||||
this.client.receive(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
|
||||||
return {
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
requestId: "req-1",
|
|
||||||
command: { command: "list_tabs", args: {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
|
||||||
return {
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
requestId: "req-new",
|
|
||||||
agentId: "agent-1",
|
|
||||||
workspaceId: "wks_workspace_a",
|
|
||||||
command: {
|
|
||||||
command: "new_tab",
|
|
||||||
args: { url: "https://example.com" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserResizeRequest(
|
|
||||||
browserId: string,
|
|
||||||
input: { workspaceId?: string } = {},
|
|
||||||
): BrowserAutomationExecuteRequest {
|
|
||||||
return {
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
requestId: "req-resize",
|
|
||||||
agentId: "agent-1",
|
|
||||||
workspaceId: input.workspaceId ?? "wks_workspace_a",
|
|
||||||
command: {
|
|
||||||
command: "resize",
|
|
||||||
args: { browserId, width: 1024, height: 768 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
|
|
||||||
return {
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
requestId: "req-close-tab",
|
|
||||||
agentId: "agent-1",
|
|
||||||
workspaceId: "wks_workspace_a",
|
|
||||||
command: {
|
|
||||||
command: "close_tab",
|
|
||||||
args: { browserId },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
|
||||||
return {
|
|
||||||
requestId,
|
|
||||||
ok: true,
|
|
||||||
result: {
|
|
||||||
command: "list_tabs",
|
|
||||||
tabs: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
|
||||||
return {
|
|
||||||
requestId,
|
|
||||||
ok: true,
|
|
||||||
result: {
|
|
||||||
command: "list_tabs",
|
|
||||||
tabs: currentBrowserTabs(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentBrowserTabs() {
|
|
||||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
|
||||||
browserId: browser.browserId,
|
|
||||||
workspaceId: "wks_workspace_a",
|
|
||||||
url: browser.url,
|
|
||||||
title: browser.title,
|
|
||||||
isActive: true,
|
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
|
|
||||||
expect(payload).toMatchObject({
|
|
||||||
requestId: "req-new",
|
|
||||||
ok: true,
|
|
||||||
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
|
|
||||||
});
|
|
||||||
if (!payload.ok || payload.result.command !== "new_tab") {
|
|
||||||
throw new Error("Expected browser_new_tab success payload");
|
|
||||||
}
|
|
||||||
return payload.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
|
|
||||||
return useWorkspaceLayoutStore
|
|
||||||
.getState()
|
|
||||||
.getWorkspaceTabs(workspaceKey)
|
|
||||||
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushAsyncWork(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForRegistrationTimeout(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserAutomationStorage = new FakeStateStorage();
|
|
||||||
useBrowserStore.persist.setOptions({
|
|
||||||
storage: createJSONStorage(() => browserAutomationStorage),
|
|
||||||
});
|
|
||||||
useWorkspaceLayoutStore.persist.setOptions({
|
|
||||||
storage: createJSONStorage(() => browserAutomationStorage),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mountBrowserAutomationHandler", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
browserAutomationStorage.clear();
|
|
||||||
useBrowserStore.setState({ browsersById: {} });
|
|
||||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: "server-1",
|
|
||||||
workspaceId: "wks_workspace_a",
|
|
||||||
});
|
|
||||||
if (!workspaceKey) {
|
|
||||||
throw new Error("Expected workspace key");
|
|
||||||
}
|
|
||||||
const previousFocusedTabId = useWorkspaceLayoutStore
|
|
||||||
.getState()
|
|
||||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
|
||||||
browser.mount({ serverId: "server-1" });
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
|
||||||
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
||||||
expect(openedTabs).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
target: { kind: "browser", browserId: result.browserId },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(layout?.root).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
kind: "pane",
|
|
||||||
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
|
||||||
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
|
||||||
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
|
||||||
]);
|
|
||||||
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
|
|
||||||
expect(browser.resident.ensuredWebviews).toEqual([
|
|
||||||
{ browserId: result.browserId, url: "https://example.com" },
|
|
||||||
]);
|
|
||||||
expect(browser.browser.executedRequests).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
requestId: "req-new:list_tabs",
|
|
||||||
agentId: "agent-1",
|
|
||||||
workspaceId: "wks_workspace_a",
|
|
||||||
command: { command: "list_tabs", args: {} },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.browser.response = emptyListTabsPayload();
|
|
||||||
browser.mount({
|
|
||||||
serverId: "server-1",
|
|
||||||
registrationWaitTimeoutMs: 1,
|
|
||||||
registrationPollIntervalMs: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await waitForRegistrationTimeout();
|
|
||||||
|
|
||||||
expect(browser.client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-new",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_timeout",
|
|
||||||
message: expect.stringContaining("Timed out waiting for browser tab"),
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(browser.resident.ensuredWebviews).toEqual([
|
|
||||||
expect.objectContaining({ url: "https://example.com" }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_new_tab wraps registration bridge errors in a response", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.browser.thrownError = new Error("IPC registration check failed");
|
|
||||||
browser.mount({ serverId: "server-1" });
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-new",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_unknown_error",
|
|
||||||
message: "IPC registration check failed",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_resize updates resident webview dimensions", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.mount({ serverId: "server-1" });
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
|
||||||
|
|
||||||
browser.receive(browserResizeRequest(result.browserId));
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.payloadAt(1)).toEqual({
|
|
||||||
requestId: "req-resize",
|
|
||||||
ok: true,
|
|
||||||
result: {
|
|
||||||
command: "resize",
|
|
||||||
browserId: result.browserId,
|
|
||||||
width: 1024,
|
|
||||||
height: 768,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(browser.browser.executedRequests).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_resize returns not found for a tab outside the request workspace", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.mount({ serverId: "server-1" });
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
|
||||||
|
|
||||||
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.payloadAt(1)).toEqual({
|
|
||||||
requestId: "req-resize",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: `No browser tab found for ID: ${result.browserId}`,
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: "server-1",
|
|
||||||
workspaceId: "wks_workspace_a",
|
|
||||||
});
|
|
||||||
if (!workspaceKey) {
|
|
||||||
throw new Error("Expected workspace key");
|
|
||||||
}
|
|
||||||
browser.mount({ serverId: "server-1" });
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
|
||||||
|
|
||||||
browser.receive(browserCloseTabRequest(result.browserId));
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.payloadAt(1)).toEqual({
|
|
||||||
requestId: "req-close-tab",
|
|
||||||
ok: true,
|
|
||||||
result: { command: "close_tab", browserId: result.browserId },
|
|
||||||
});
|
|
||||||
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
|
|
||||||
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
|
|
||||||
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
|
|
||||||
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
|
|
||||||
expect(currentBrowserTabs()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("browser_close_tab returns not found after the tab is gone", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.mount({ serverId: "server-1" });
|
|
||||||
|
|
||||||
browser.receive(browserNewTabRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
|
||||||
|
|
||||||
browser.receive(browserCloseTabRequest(result.browserId));
|
|
||||||
await flushAsyncWork();
|
|
||||||
browser.receive(browserCloseTabRequest(result.browserId));
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.payloadAt(2)).toEqual({
|
|
||||||
requestId: "req-close-tab",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: `No browser tab found for ID: ${result.browserId}`,
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-new-tab requests send the desktop bridge response", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.browser.response = {
|
|
||||||
requestId: "desktop-req",
|
|
||||||
ok: true,
|
|
||||||
result: { command: "list_tabs", tabs: [] },
|
|
||||||
};
|
|
||||||
browser.mount();
|
|
||||||
const request = browserAutomationRequest();
|
|
||||||
|
|
||||||
browser.receive(request);
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.browser.executedRequests).toEqual([request]);
|
|
||||||
expect(browser.client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: true,
|
|
||||||
result: { command: "list_tabs", tabs: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing desktop bridge sends browser_unsupported", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.mount({ host: null });
|
|
||||||
|
|
||||||
browser.receive(browserAutomationRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Browser automation is not available in this app runtime.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("typed bridge errors become failure responses", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.browser.thrownError = {
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
|
||||||
retryable: false,
|
|
||||||
};
|
|
||||||
browser.mount();
|
|
||||||
|
|
||||||
browser.receive(browserAutomationRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.browser.thrownError = new Error(
|
|
||||||
'No handler registered for "paseo:browser:execute-automation-command"',
|
|
||||||
);
|
|
||||||
browser.mount();
|
|
||||||
|
|
||||||
browser.receive(browserAutomationRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Browser automation is not implemented by this app build yet.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unsubscribe stops handling browser automation requests", async () => {
|
|
||||||
const browser = new BrowserAutomationHandlerHarness();
|
|
||||||
browser.mount();
|
|
||||||
|
|
||||||
browser.unmount();
|
|
||||||
browser.receive(browserAutomationRequest());
|
|
||||||
await flushAsyncWork();
|
|
||||||
|
|
||||||
expect(browser.browser.executedRequests).toEqual([]);
|
|
||||||
expect(browser.client.sentResponses).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
||||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
|
||||||
import {
|
|
||||||
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
|
|
||||||
removeResidentBrowserWebview,
|
|
||||||
resizeResidentBrowserWebview,
|
|
||||||
} from "@/components/browser-webview-resident";
|
|
||||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
|
||||||
import {
|
|
||||||
buildWorkspaceTabPersistenceKey,
|
|
||||||
collectAllTabs,
|
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "browser.automation.execute.request" }
|
|
||||||
>;
|
|
||||||
type BrowserAutomationExecuteResponse = Extract<
|
|
||||||
SessionInboundMessage,
|
|
||||||
{ type: "browser.automation.execute.response" }
|
|
||||||
>;
|
|
||||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
|
||||||
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
|
|
||||||
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
|
|
||||||
|
|
||||||
interface BrowserAutomationClient {
|
|
||||||
on(
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
handler: (message: BrowserAutomationExecuteRequest) => void,
|
|
||||||
): () => void;
|
|
||||||
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrowserAutomationHandlerOptions {
|
|
||||||
client: BrowserAutomationClient;
|
|
||||||
serverId?: string;
|
|
||||||
getHost?: () => DesktopHostBridge | null;
|
|
||||||
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
|
|
||||||
registrationWaitTimeoutMs?: number;
|
|
||||||
registrationPollIntervalMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountBrowserAutomationHandler(
|
|
||||||
options: BrowserAutomationHandlerOptions,
|
|
||||||
): () => void {
|
|
||||||
const getHost = options.getHost ?? getDesktopHost;
|
|
||||||
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
|
|
||||||
void handleBrowserAutomationRequest({
|
|
||||||
client: options.client,
|
|
||||||
getHost,
|
|
||||||
request,
|
|
||||||
serverId: options.serverId,
|
|
||||||
ensureResidentBrowserWebview:
|
|
||||||
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
|
|
||||||
...(options.registrationWaitTimeoutMs !== undefined
|
|
||||||
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
|
|
||||||
: {}),
|
|
||||||
...(options.registrationPollIntervalMs !== undefined
|
|
||||||
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountBrowserAutomationDaemonClientHandler(
|
|
||||||
client: unknown,
|
|
||||||
options?: { serverId?: string },
|
|
||||||
): () => void {
|
|
||||||
return mountBrowserAutomationHandler({
|
|
||||||
client: client as BrowserAutomationClient,
|
|
||||||
...(options?.serverId ? { serverId: options.serverId } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBrowserAutomationRequest(params: {
|
|
||||||
client: BrowserAutomationHandlerOptions["client"];
|
|
||||||
getHost: () => DesktopHostBridge | null;
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
serverId?: string;
|
|
||||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
|
||||||
registrationWaitTimeoutMs?: number;
|
|
||||||
registrationPollIntervalMs?: number;
|
|
||||||
}): Promise<void> {
|
|
||||||
const {
|
|
||||||
client,
|
|
||||||
getHost,
|
|
||||||
request,
|
|
||||||
serverId,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
registrationWaitTimeoutMs,
|
|
||||||
registrationPollIntervalMs,
|
|
||||||
} = params;
|
|
||||||
const browserHost = getHost()?.browser;
|
|
||||||
const executeAutomationCommand = browserHost?.executeAutomationCommand;
|
|
||||||
|
|
||||||
if (request.command.command === "new_tab") {
|
|
||||||
try {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: await openBrowserTabForRequest({
|
|
||||||
request,
|
|
||||||
serverId,
|
|
||||||
browserHost,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
|
|
||||||
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.command.command === "resize") {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: resizeBrowserTabForRequest({ request, serverId }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.command.command === "close_tab") {
|
|
||||||
try {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: await closeBrowserTabForRequest({
|
|
||||||
request,
|
|
||||||
serverId,
|
|
||||||
browserHost,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!executeAutomationCommand) {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Browser automation is not available in this app runtime.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await executeAutomationCommand(request);
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: normalizeBridgePayload(request.requestId, payload),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resizeBrowserTabForRequest(params: {
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
serverId?: string;
|
|
||||||
}): BrowserAutomationResponsePayload {
|
|
||||||
const { request, serverId } = params;
|
|
||||||
const command = request.command as Extract<
|
|
||||||
BrowserAutomationExecuteRequest["command"],
|
|
||||||
{ command: "resize" }
|
|
||||||
>;
|
|
||||||
const browserId = command.args.browserId;
|
|
||||||
if (!getBrowserRecord(browserId)) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: `No browser tab found for ID: ${browserId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceId = request.workspaceId;
|
|
||||||
if (serverId && workspaceId && !findWorkspaceBrowserTab({ serverId, workspaceId, browserId })) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: `No browser tab found for ID: ${browserId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dimensions = resizeResidentBrowserWebview({
|
|
||||||
browserId,
|
|
||||||
width: command.args.width,
|
|
||||||
height: command.args.height,
|
|
||||||
});
|
|
||||||
if (!dimensions) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: `No browser tab found for ID: ${browserId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: request.requestId,
|
|
||||||
ok: true,
|
|
||||||
result: {
|
|
||||||
command: "resize",
|
|
||||||
browserId,
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeBrowserTabForRequest(params: {
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
serverId?: string;
|
|
||||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
|
||||||
}): Promise<BrowserAutomationResponsePayload> {
|
|
||||||
const { request, serverId, browserHost } = params;
|
|
||||||
const command = request.command as Extract<
|
|
||||||
BrowserAutomationExecuteRequest["command"],
|
|
||||||
{ command: "close_tab" }
|
|
||||||
>;
|
|
||||||
const browserId = command.args.browserId;
|
|
||||||
const workspaceId = request.workspaceId;
|
|
||||||
const workspaceTab = serverId
|
|
||||||
? findWorkspaceBrowserTab({ serverId, workspaceId, browserId })
|
|
||||||
: null;
|
|
||||||
if (!workspaceTab && (!serverId || !workspaceId)) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Cannot close a browser tab without a workspace context.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!workspaceTab || !getBrowserRecord(browserId)) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: `No browser tab found for ID: ${browserId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useWorkspaceLayoutStore.getState().closeTab(workspaceTab.workspaceKey, workspaceTab.tabId);
|
|
||||||
useBrowserStore.getState().removeBrowser(browserId);
|
|
||||||
removeResidentBrowserWebview(browserId);
|
|
||||||
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
|
|
||||||
await browserHost?.clearPartition?.(browserId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: request.requestId,
|
|
||||||
ok: true,
|
|
||||||
result: { command: "close_tab", browserId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findWorkspaceBrowserTab(input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string | undefined;
|
|
||||||
browserId: string;
|
|
||||||
}): { workspaceKey: string; tabId: string } | null {
|
|
||||||
if (!input.workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: input.serverId,
|
|
||||||
workspaceId: input.workspaceId,
|
|
||||||
});
|
|
||||||
if (!workspaceKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
||||||
const tab = layout
|
|
||||||
? collectAllTabs(layout.root).find((candidate) => {
|
|
||||||
return (
|
|
||||||
candidate.target.kind === "browser" && candidate.target.browserId === input.browserId
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
return tab ? { workspaceKey, tabId: tab.tabId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openBrowserTabForRequest(params: {
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
serverId?: string;
|
|
||||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
|
||||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
|
||||||
registrationWaitTimeoutMs?: number;
|
|
||||||
registrationPollIntervalMs?: number;
|
|
||||||
}): Promise<BrowserAutomationResponsePayload> {
|
|
||||||
const {
|
|
||||||
request,
|
|
||||||
serverId,
|
|
||||||
browserHost,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
registrationWaitTimeoutMs,
|
|
||||||
registrationPollIntervalMs,
|
|
||||||
} = params;
|
|
||||||
const command = request.command as Extract<
|
|
||||||
BrowserAutomationExecuteRequest["command"],
|
|
||||||
{ command: "new_tab" }
|
|
||||||
>;
|
|
||||||
const workspaceId = request.workspaceId;
|
|
||||||
if (!serverId || !workspaceId) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = command.args.url ?? "https://example.com";
|
|
||||||
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
if (!workspaceKey) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
|
|
||||||
kind: "browser",
|
|
||||||
browserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
|
||||||
|
|
||||||
if (browserHost?.executeAutomationCommand) {
|
|
||||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
|
||||||
const registered = await waitForBrowserRegistration({
|
|
||||||
request,
|
|
||||||
browserId,
|
|
||||||
workspaceId,
|
|
||||||
executeAutomationCommand: browserHost.executeAutomationCommand,
|
|
||||||
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
|
|
||||||
...(registrationPollIntervalMs !== undefined
|
|
||||||
? { pollIntervalMs: registrationPollIntervalMs }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
if (!registered) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_timeout",
|
|
||||||
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
|
|
||||||
retryable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: request.requestId,
|
|
||||||
ok: true,
|
|
||||||
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForBrowserRegistration(params: {
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
browserId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
executeAutomationCommand: (
|
|
||||||
request: BrowserAutomationExecuteRequest,
|
|
||||||
) => Promise<BrowserAutomationResponsePayload>;
|
|
||||||
timeoutMs?: number;
|
|
||||||
pollIntervalMs?: number;
|
|
||||||
}): Promise<boolean> {
|
|
||||||
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const payload = await params.executeAutomationCommand({
|
|
||||||
type: "browser.automation.execute.request",
|
|
||||||
requestId: `${params.request.requestId}:list_tabs`,
|
|
||||||
agentId: params.request.agentId,
|
|
||||||
cwd: params.request.cwd,
|
|
||||||
workspaceId: params.workspaceId,
|
|
||||||
command: { command: "list_tabs", args: {} },
|
|
||||||
});
|
|
||||||
if (payload.ok && payload.result.command === "list_tabs") {
|
|
||||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await delay(params.pollIntervalMs ?? 100);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBridgePayload(
|
|
||||||
requestId: string,
|
|
||||||
payload: BrowserAutomationResponsePayload,
|
|
||||||
): BrowserAutomationResponsePayload {
|
|
||||||
return { ...payload, requestId } as BrowserAutomationResponsePayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeThrownBridgeError(
|
|
||||||
requestId: string,
|
|
||||||
error: unknown,
|
|
||||||
): BrowserAutomationFailurePayload {
|
|
||||||
const typed = readTypedBrowserAutomationError(error);
|
|
||||||
if (typed) {
|
|
||||||
return browserAutomationFailure({ requestId, ...typed });
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (message.includes("No handler registered")) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId,
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Browser automation is not implemented by this app build yet.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId,
|
|
||||||
code: "browser_unknown_error",
|
|
||||||
message: message || "Browser automation failed.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readTypedBrowserAutomationError(
|
|
||||||
value: unknown,
|
|
||||||
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof record.message !== "string" || record.message.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code: record.code as BrowserAutomationErrorCode,
|
|
||||||
message: record.message,
|
|
||||||
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserAutomationFailure(params: {
|
|
||||||
requestId: string;
|
|
||||||
code: BrowserAutomationErrorCode;
|
|
||||||
message: string;
|
|
||||||
retryable?: boolean;
|
|
||||||
}): BrowserAutomationFailurePayload {
|
|
||||||
return {
|
|
||||||
requestId: params.requestId,
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: params.code,
|
|
||||||
message: params.message,
|
|
||||||
retryable: params.retryable ?? false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -20,9 +20,7 @@ import {
|
|||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||||
@@ -180,11 +178,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
desktopScrollContainer: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -221,7 +214,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
adaptiveInputOutline: {
|
adaptiveInputOutline: {
|
||||||
...createControlGeometry(theme).controlFocusRingColor,
|
outlineColor: theme.colors.accent,
|
||||||
},
|
},
|
||||||
adaptiveInputText: {
|
adaptiveInputText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -232,7 +225,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||||
const WEB_EXIT_DURATION_MS = 160;
|
|
||||||
|
|
||||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -450,7 +442,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
header: SheetHeader;
|
header: SheetHeader;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDismiss?: () => void;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Sticky footer rendered below the scrollable content. */
|
/** Sticky footer rendered below the scrollable content. */
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
@@ -460,18 +451,12 @@ export interface AdaptiveModalSheetProps {
|
|||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
presentation?: "push" | "replace";
|
presentation?: "push" | "replace";
|
||||||
/**
|
|
||||||
* Render the themed desktop-web scrollbar over the scroll area instead of the
|
|
||||||
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
|
|
||||||
*/
|
|
||||||
webScrollbar?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdaptiveModalSheet({
|
export function AdaptiveModalSheet({
|
||||||
header,
|
header,
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
onDismiss,
|
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
@@ -479,16 +464,11 @@ export function AdaptiveModalSheet({
|
|||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
scrollable = true,
|
scrollable = true,
|
||||||
presentation,
|
presentation,
|
||||||
webScrollbar = false,
|
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const desktopScrollRef = useRef<ScrollView>(null);
|
|
||||||
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
|
||||||
enabled: webScrollbar && !isMobile,
|
|
||||||
});
|
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
const compactSafeAreaPadding = useMemo(
|
const compactSafeAreaPadding = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -537,20 +517,6 @@ export function AdaptiveModalSheet({
|
|||||||
isEnabled: isMobile,
|
isEnabled: isMobile,
|
||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
|
||||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
|
||||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
handleSheetDismiss();
|
|
||||||
onDismiss?.();
|
|
||||||
}, [handleSheetDismiss, onDismiss]);
|
|
||||||
const notifyNativeModalDismiss = useCallback(() => {
|
|
||||||
if (nativeModalDismissNotifiedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nativeModalDismissNotifiedRef.current = true;
|
|
||||||
onDismiss?.();
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -563,53 +529,12 @@ export function AdaptiveModalSheet({
|
|||||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||||
[desktopMaxWidth],
|
[desktopMaxWidth],
|
||||||
);
|
);
|
||||||
const desktopOverlayStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.desktopOverlay,
|
|
||||||
isWeb && {
|
|
||||||
opacity: isWebClosing ? 0 : 1,
|
|
||||||
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
|
||||||
transitionProperty: "opacity",
|
|
||||||
transitionTimingFunction: "ease",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[isWebClosing],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || isMobile || !visible) return;
|
if (!isWeb || isMobile || !visible) return;
|
||||||
return pushEscHandler(onClose);
|
return pushEscHandler(onClose);
|
||||||
}, [visible, isMobile, onClose]);
|
}, [visible, isMobile, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
nativeModalDismissNotifiedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWeb || isMobile) return;
|
|
||||||
if (visible) {
|
|
||||||
setShouldRenderWeb(true);
|
|
||||||
setIsWebClosing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!shouldRenderWeb) return;
|
|
||||||
setIsWebClosing(true);
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
setShouldRenderWeb(false);
|
|
||||||
setIsWebClosing(false);
|
|
||||||
onDismiss?.();
|
|
||||||
}, WEB_EXIT_DURATION_MS);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
|
||||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<IsolatedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
@@ -618,7 +543,7 @@ export function AdaptiveModalSheet({
|
|||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
onDismiss={handleDismiss}
|
onDismiss={handleSheetDismiss}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
@@ -649,22 +574,13 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<SheetHeaderView header={header} onClose={onClose} />
|
<SheetHeaderView header={header} onClose={onClose} />
|
||||||
{scrollable ? (
|
{scrollable ? (
|
||||||
<View style={styles.desktopScrollContainer}>
|
<ScrollView
|
||||||
<ScrollView
|
style={styles.desktopScroll}
|
||||||
ref={desktopScrollRef}
|
contentContainerStyle={styles.desktopContent}
|
||||||
style={styles.desktopScroll}
|
keyboardShouldPersistTaps="handled"
|
||||||
contentContainerStyle={styles.desktopContent}
|
>
|
||||||
keyboardShouldPersistTaps="handled"
|
{children}
|
||||||
onLayout={desktopScrollbar.onLayout}
|
</ScrollView>
|
||||||
onScroll={desktopScrollbar.onScroll}
|
|
||||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
showsVerticalScrollIndicator={!webScrollbar}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollView>
|
|
||||||
{desktopScrollbar.overlay}
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.desktopStaticContent}>{children}</View>
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
@@ -673,7 +589,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const desktopContent = (
|
const desktopContent = (
|
||||||
<View style={desktopOverlayStyle} testID={testID}>
|
<View style={styles.desktopOverlay} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityLabel={t("common.actions.dismiss")}
|
accessibilityLabel={t("common.actions.dismiss")}
|
||||||
style={ABSOLUTE_FILL_STYLE}
|
style={ABSOLUTE_FILL_STYLE}
|
||||||
@@ -685,7 +601,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 (isWeb && typeof document !== "undefined") {
|
||||||
if (!shouldRenderWeb) return null;
|
if (!visible) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +611,6 @@ export function AdaptiveModalSheet({
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
onDismiss={notifyNativeModalDismiss}
|
|
||||||
hardwareAccelerated
|
hardwareAccelerated
|
||||||
>
|
>
|
||||||
{desktopContent}
|
{desktopContent}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import {
|
||||||
formatAppDiagnosticHeader,
|
formatAppDiagnosticHeader,
|
||||||
formatDiagnosticSection,
|
formatDiagnosticSection,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
formatServerInfoSection,
|
formatServerInfoSection,
|
||||||
redactAppDiagnosticReport,
|
redactAppDiagnosticReport,
|
||||||
} from "@/diagnostics/app-diagnostic-report";
|
} from "@/diagnostics/app-diagnostic-report";
|
||||||
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
|
|
||||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
@@ -261,6 +261,35 @@ export function AppDiagnosticSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
|
||||||
|
try {
|
||||||
|
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
sections: [
|
||||||
|
formatDiagnosticSection("Desktop", [
|
||||||
|
{ label: "Daemon status", value: status.status },
|
||||||
|
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
||||||
|
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
||||||
|
{ label: "Daemon version", value: status.version ?? "unknown" },
|
||||||
|
{ label: "Daemon home", value: status.home || "unknown" },
|
||||||
|
{ label: "Log path", value: logs.logPath || "unknown" },
|
||||||
|
{ label: "Error", value: status.error ?? "none" },
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
"Desktop daemon log tail",
|
||||||
|
logs.contents ? indentBlock(logs.contents) : " No log lines found",
|
||||||
|
].join("\n"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function collectHostDiagnosticSections(
|
async function collectHostDiagnosticSections(
|
||||||
host: HostProfile,
|
host: HostProfile,
|
||||||
snapshot: HostRuntimeSnapshot | null,
|
snapshot: HostRuntimeSnapshot | null,
|
||||||
@@ -309,6 +338,14 @@ async function collectHostDiagnosticSections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function indentBlock(value: string): string {
|
||||||
|
return value
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function toMessage(error: unknown): string {
|
function toMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
clearResidentBrowserWebviewsForTests,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
prepareBrowserWebview,
|
|
||||||
releaseResidentBrowserWebview,
|
|
||||||
removeResidentBrowserWebview,
|
|
||||||
takeResidentBrowserWebview,
|
|
||||||
} from "./browser-webview-resident";
|
|
||||||
|
|
||||||
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
|
|
||||||
|
|
||||||
function residentHost(): HTMLElement {
|
|
||||||
const host = document.getElementById(RESIDENT_HOST_ID);
|
|
||||||
if (!host) {
|
|
||||||
throw new Error("Expected resident browser host");
|
|
||||||
}
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectPermanentHostParking(host: HTMLElement): void {
|
|
||||||
expect(host.style.position).toBe("fixed");
|
|
||||||
expect(host.style.left).toBe("0px");
|
|
||||||
expect(host.style.top).toBe("0px");
|
|
||||||
expect(host.style.width).toBe("1px");
|
|
||||||
expect(host.style.height).toBe("1px");
|
|
||||||
expect(host.style.overflow).toBe("hidden");
|
|
||||||
expect(host.style.opacity).toBe("1");
|
|
||||||
expect(host.style.pointerEvents).toBe("none");
|
|
||||||
expect(host.style.display).toBe("block");
|
|
||||||
expect(host.style.visibility).toBe("visible");
|
|
||||||
expect(host.style.transform).toBe("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectResidentWebviewParking(webview: HTMLElement): void {
|
|
||||||
expect(webview.style.display).toBe("inline-flex");
|
|
||||||
expect(webview.style.flex).toBe("0 0 auto");
|
|
||||||
expect(webview.style.width).toBe("1280px");
|
|
||||||
expect(webview.style.height).toBe("800px");
|
|
||||||
expect(webview.style.position).toBe("absolute");
|
|
||||||
expect(webview.style.left).toBe("0px");
|
|
||||||
expect(webview.style.top).toBe("0px");
|
|
||||||
expect(webview.style.zIndex).toBe("0");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("resident browser webviews", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
clearResidentBrowserWebviewsForTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parks a browser webview in the permanent paintable 1x1 host", () => {
|
|
||||||
const visibleHost = document.createElement("div");
|
|
||||||
const webview = document.createElement("webview");
|
|
||||||
visibleHost.appendChild(webview);
|
|
||||||
document.body.appendChild(visibleHost);
|
|
||||||
|
|
||||||
releaseResidentBrowserWebview("browser-a", webview);
|
|
||||||
|
|
||||||
const host = residentHost();
|
|
||||||
expect(visibleHost.children).toHaveLength(0);
|
|
||||||
expect(Array.from(host.children)).toEqual([webview]);
|
|
||||||
expect(webview.isConnected).toBe(true);
|
|
||||||
expectPermanentHostParking(host);
|
|
||||||
expectResidentWebviewParking(webview);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
|
||||||
const webview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-agent",
|
|
||||||
url: "https://example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(webview).not.toBeNull();
|
|
||||||
expect(webview?.isConnected).toBe(true);
|
|
||||||
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
|
|
||||||
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
|
|
||||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
|
||||||
"https://example.com",
|
|
||||||
);
|
|
||||||
expectPermanentHostParking(residentHost());
|
|
||||||
expectResidentWebviewParking(webview as HTMLElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes an existing resident host back to permanent parking", () => {
|
|
||||||
const staleHost = document.createElement("div");
|
|
||||||
staleHost.id = RESIDENT_HOST_ID;
|
|
||||||
staleHost.style.left = "-20000px";
|
|
||||||
staleHost.style.width = "1280px";
|
|
||||||
staleHost.style.height = "800px";
|
|
||||||
staleHost.style.opacity = "0";
|
|
||||||
staleHost.style.display = "none";
|
|
||||||
document.body.appendChild(staleHost);
|
|
||||||
|
|
||||||
const webview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-stale-host",
|
|
||||||
url: "https://example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(webview).not.toBeNull();
|
|
||||||
expectPermanentHostParking(staleHost);
|
|
||||||
expectResidentWebviewParking(webview as HTMLElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes an existing resident webview and its stale host before reusing them", () => {
|
|
||||||
const staleHost = document.createElement("div");
|
|
||||||
staleHost.id = RESIDENT_HOST_ID;
|
|
||||||
staleHost.style.left = "-20000px";
|
|
||||||
staleHost.style.width = "1280px";
|
|
||||||
staleHost.style.height = "800px";
|
|
||||||
staleHost.style.opacity = "0";
|
|
||||||
staleHost.style.display = "none";
|
|
||||||
|
|
||||||
const staleWebview = document.createElement("webview");
|
|
||||||
prepareBrowserWebview(staleWebview, {
|
|
||||||
browserId: "browser-stale-child",
|
|
||||||
initialUrl: "https://example.com",
|
|
||||||
});
|
|
||||||
staleWebview.style.display = "none";
|
|
||||||
staleWebview.style.width = "1px";
|
|
||||||
staleWebview.style.height = "1px";
|
|
||||||
staleWebview.style.position = "fixed";
|
|
||||||
staleWebview.style.left = "-20000px";
|
|
||||||
staleHost.appendChild(staleWebview);
|
|
||||||
document.body.appendChild(staleHost);
|
|
||||||
|
|
||||||
const webview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-stale-child",
|
|
||||||
url: "https://example.com/agent",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(webview).toBe(staleWebview);
|
|
||||||
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
|
|
||||||
expectPermanentHostParking(staleHost);
|
|
||||||
expectResidentWebviewParking(staleWebview);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parks resident webviews as an overlapping stack", () => {
|
|
||||||
const firstWebview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-first",
|
|
||||||
url: "https://example.com/first",
|
|
||||||
});
|
|
||||||
const secondWebview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-second",
|
|
||||||
url: "https://example.com/second",
|
|
||||||
});
|
|
||||||
|
|
||||||
const host = residentHost();
|
|
||||||
expect(firstWebview?.parentElement).toBe(host);
|
|
||||||
expect(secondWebview?.parentElement).toBe(host);
|
|
||||||
expectResidentWebviewParking(firstWebview as HTMLElement);
|
|
||||||
expectResidentWebviewParking(secondWebview as HTMLElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves a resident webview into a visible pane without recreating the node", () => {
|
|
||||||
const webview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-visible",
|
|
||||||
url: "https://example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleWebview = takeResidentBrowserWebview("browser-visible");
|
|
||||||
|
|
||||||
expect(visibleWebview).toBe(webview);
|
|
||||||
expect(webview?.style.position).toBe("");
|
|
||||||
expect(webview?.style.left).toBe("");
|
|
||||||
expect(webview?.style.top).toBe("");
|
|
||||||
expect(webview?.style.zIndex).toBe("");
|
|
||||||
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
|
||||||
const visibleHost = document.createElement("div");
|
|
||||||
const visibleWebview = document.createElement("webview");
|
|
||||||
prepareBrowserWebview(visibleWebview, {
|
|
||||||
browserId: "browser-visible-pane",
|
|
||||||
initialUrl: "https://example.com",
|
|
||||||
});
|
|
||||||
visibleHost.appendChild(visibleWebview);
|
|
||||||
document.body.appendChild(visibleHost);
|
|
||||||
|
|
||||||
const webview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-visible-pane",
|
|
||||||
url: "https://example.com/agent",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(webview).toBe(visibleWebview);
|
|
||||||
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a resident webview when its browser tab closes", () => {
|
|
||||||
const webview = ensureResidentBrowserWebview({
|
|
||||||
browserId: "browser-closed",
|
|
||||||
url: "https://example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
removeResidentBrowserWebview("browser-closed");
|
|
||||||
|
|
||||||
expect(webview?.isConnected).toBe(false);
|
|
||||||
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
|
||||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
|
||||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
|
||||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
|
||||||
|
|
||||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
|
||||||
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
|
||||||
|
|
||||||
interface BrowserWebviewElement extends HTMLElement {
|
|
||||||
src: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDocument(): Document | null {
|
|
||||||
return typeof document === "undefined" ? null : document;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyResidentHostParkingStyle(host: HTMLElement): void {
|
|
||||||
// Parked browser webviews must remain paintable at all times; screenshot
|
|
||||||
// correctness depends on the proven states in docs/browser-capture-harness.md.
|
|
||||||
host.setAttribute("aria-hidden", "true");
|
|
||||||
host.style.position = "fixed";
|
|
||||||
host.style.left = "0";
|
|
||||||
host.style.top = "0";
|
|
||||||
host.style.width = "1px";
|
|
||||||
host.style.height = "1px";
|
|
||||||
host.style.overflow = "hidden";
|
|
||||||
host.style.opacity = "1";
|
|
||||||
host.style.pointerEvents = "none";
|
|
||||||
host.style.display = "block";
|
|
||||||
host.style.zIndex = "";
|
|
||||||
host.style.clipPath = "";
|
|
||||||
host.style.visibility = "visible";
|
|
||||||
host.style.transform = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
|
||||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
|
||||||
if (existing) {
|
|
||||||
applyResidentHostParkingStyle(existing);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = ownerDocument.createElement("div");
|
|
||||||
host.id = RESIDENT_BROWSER_HOST_ID;
|
|
||||||
applyResidentHostParkingStyle(host);
|
|
||||||
ownerDocument.body.appendChild(host);
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
|
||||||
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
|
||||||
if (!(element instanceof HTMLElement)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
|
|
||||||
if (!browserId) {
|
|
||||||
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
residentWebviewSizesByBrowserId.get(browserId) ?? {
|
|
||||||
width: RESIDENT_VIEWPORT_WIDTH,
|
|
||||||
height: RESIDENT_VIEWPORT_HEIGHT,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
|
|
||||||
const dimensions = dimensionsForBrowser(browserId);
|
|
||||||
webview.style.display = "inline-flex";
|
|
||||||
webview.style.flex = "0 0 auto";
|
|
||||||
webview.style.width = `${dimensions.width}px`;
|
|
||||||
webview.style.height = `${dimensions.height}px`;
|
|
||||||
webview.style.border = "0";
|
|
||||||
webview.style.background = "transparent";
|
|
||||||
webview.style.position = "absolute";
|
|
||||||
webview.style.left = "0";
|
|
||||||
webview.style.top = "0";
|
|
||||||
webview.style.marginTop = "0";
|
|
||||||
webview.style.zIndex = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
|
||||||
webview.style.position = "";
|
|
||||||
webview.style.left = "";
|
|
||||||
webview.style.top = "";
|
|
||||||
webview.style.marginTop = "";
|
|
||||||
webview.style.zIndex = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prepareBrowserWebview(
|
|
||||||
webview: HTMLElement,
|
|
||||||
input: { browserId: string; initialUrl?: string | null },
|
|
||||||
): void {
|
|
||||||
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
|
|
||||||
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
|
|
||||||
webview.setAttribute("allowpopups", "true");
|
|
||||||
webview.setAttribute("spellcheck", "false");
|
|
||||||
webview.setAttribute("autosize", "on");
|
|
||||||
if (input.initialUrl) {
|
|
||||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureResidentBrowserWebview(input: {
|
|
||||||
browserId: string;
|
|
||||||
url: string;
|
|
||||||
}): HTMLElement | null {
|
|
||||||
const browserId = trimNonEmpty(input.browserId);
|
|
||||||
if (!browserId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const ownerDocument = readDocument();
|
|
||||||
if (!ownerDocument) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
|
||||||
if (resident?.isConnected) {
|
|
||||||
releaseResidentBrowserWebview(browserId, resident);
|
|
||||||
return resident;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
|
|
||||||
releaseResidentBrowserWebview(browserId, existing);
|
|
||||||
}
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
|
|
||||||
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
|
|
||||||
releaseResidentBrowserWebview(browserId, webview);
|
|
||||||
return webview;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
|
|
||||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
|
||||||
if (!normalizedBrowserId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
|
||||||
if (!webview) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
|
||||||
clearResidentWebviewParkingStyle(webview);
|
|
||||||
return webview;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
|
|
||||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
|
||||||
if (!normalizedBrowserId) {
|
|
||||||
webview.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ownerDocument = readDocument();
|
|
||||||
if (!ownerDocument) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
|
||||||
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
|
||||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resizeResidentBrowserWebview(input: {
|
|
||||||
browserId: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}): { width: number; height: number } | null {
|
|
||||||
const normalizedBrowserId = trimNonEmpty(input.browserId);
|
|
||||||
if (!normalizedBrowserId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const width = Math.max(1, Math.round(input.width));
|
|
||||||
const height = Math.max(1, Math.round(input.height));
|
|
||||||
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
|
|
||||||
|
|
||||||
const ownerDocument = readDocument();
|
|
||||||
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
|
||||||
if (webview) {
|
|
||||||
webview.style.width = `${width}px`;
|
|
||||||
webview.style.height = `${height}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeResidentBrowserWebview(browserId: string): void {
|
|
||||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
|
||||||
if (!normalizedBrowserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
|
||||||
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
|
||||||
resident?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearResidentBrowserWebviewsForTests(): void {
|
|
||||||
for (const webview of residentWebviewsByBrowserId.values()) {
|
|
||||||
webview.remove();
|
|
||||||
}
|
|
||||||
residentWebviewsByBrowserId.clear();
|
|
||||||
residentWebviewSizesByBrowserId.clear();
|
|
||||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
|
||||||
}
|
|
||||||
@@ -4,39 +4,24 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxItem,
|
|
||||||
type ComboboxOption,
|
|
||||||
type ComboboxProps,
|
|
||||||
} from "@/components/ui/combobox";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import {
|
|
||||||
buildSelectedTriggerLabel,
|
|
||||||
filterAndRankModelRows,
|
|
||||||
getAllProviderModelRows,
|
|
||||||
getProviderModelRows,
|
|
||||||
resolveSelectedModelLabel,
|
|
||||||
type ProviderSelectionModelRow,
|
|
||||||
type ProviderSelectorProvider,
|
|
||||||
} from "@/provider-selection/provider-selection";
|
|
||||||
|
|
||||||
const IS_WEB = platformIsWeb;
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
|
||||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
@@ -62,67 +47,22 @@ function drillDownRowStyle({
|
|||||||
pressed && styles.drillDownRowPressed,
|
pressed && styles.drillDownRowPressed,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import {
|
||||||
|
buildSelectedTriggerLabel,
|
||||||
|
filterAndRankModelRows,
|
||||||
|
getAllProviderModelRows,
|
||||||
|
getProviderModelRows,
|
||||||
|
resolveSelectedModelLabel,
|
||||||
|
type ProviderSelectionModelRow,
|
||||||
|
type ProviderSelectorProvider,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||||
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
||||||
|
|
||||||
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
|
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
||||||
const ThemedSearch = withUnistyles(Search);
|
|
||||||
const ThemedSettings = withUnistyles(Settings);
|
|
||||||
const ThemedStar = withUnistyles(Star);
|
|
||||||
|
|
||||||
const foregroundMutedMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
|
|
||||||
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
const favoriteStarMapping =
|
|
||||||
(isFavorite: boolean, hovered: boolean) =>
|
|
||||||
(theme: Theme): { color: string; fill: string } => {
|
|
||||||
const favoriteColor = theme.colors.palette.amber[500];
|
|
||||||
if (isFavorite) {
|
|
||||||
return { color: favoriteColor, fill: favoriteColor };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
|
|
||||||
fill: "transparent",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProviderGlyphTone = "muted" | "foreground";
|
|
||||||
|
|
||||||
function ProviderGlyph({
|
|
||||||
provider,
|
|
||||||
size,
|
|
||||||
tone = "muted",
|
|
||||||
}: {
|
|
||||||
provider: string;
|
|
||||||
size: number;
|
|
||||||
tone?: ProviderGlyphTone;
|
|
||||||
}) {
|
|
||||||
const Icon = getProviderIcon(provider);
|
|
||||||
const color =
|
|
||||||
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
|
||||||
return <Icon size={size} color={color} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
|
|
||||||
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
|
|
||||||
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
|
|
||||||
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
|
|
||||||
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectorView =
|
type SelectorView =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
@@ -140,8 +80,6 @@ interface CombinedModelSelectorProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
hovered: boolean;
|
|
||||||
pressed: boolean;
|
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -149,17 +87,6 @@ interface CombinedModelSelectorProps {
|
|||||||
isRetryingProvider?: boolean;
|
isRetryingProvider?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
|
||||||
desktopMinWidth?: number;
|
|
||||||
/**
|
|
||||||
* Render the custom trigger as a full-width form field: the outer Pressable
|
|
||||||
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
|
||||||
* stops painting its own hover/pressed background and rounded corners. The
|
|
||||||
* trigger itself owns the field visuals and reads hovered/pressed to show its
|
|
||||||
* active state. Without this the trigger stays a content-width toolbar chip
|
|
||||||
* (the composer's layout).
|
|
||||||
*/
|
|
||||||
triggerFill?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectorContentProps {
|
interface SelectorContentProps {
|
||||||
@@ -211,7 +138,9 @@ function ModelRow({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const ProviderIcon = getProviderIcon(row.provider);
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
@@ -222,8 +151,8 @@ function ModelRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const leadingSlot = useMemo(
|
const leadingSlot = useMemo(
|
||||||
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
[row.provider],
|
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
const trailingSlot = useMemo(
|
const trailingSlot = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -238,10 +167,32 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||||
>
|
>
|
||||||
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
|
{({ hovered }) => {
|
||||||
|
let starColor: string;
|
||||||
|
if (isFavorite) starColor = theme.colors.palette.amber[500];
|
||||||
|
else if (hovered) starColor = theme.colors.foregroundMuted;
|
||||||
|
else starColor = theme.colors.border;
|
||||||
|
return (
|
||||||
|
<Star
|
||||||
|
size={16}
|
||||||
|
color={starColor}
|
||||||
|
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null,
|
) : null,
|
||||||
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
|
[
|
||||||
|
onToggleFavorite,
|
||||||
|
handleToggleFavorite,
|
||||||
|
isFavorite,
|
||||||
|
row.provider,
|
||||||
|
row.modelId,
|
||||||
|
theme.colors.palette.amber,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
theme.colors.border,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,7 +294,9 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const ProvIcon = getProviderIcon(provider.id);
|
||||||
const selection = provider.modelSelection;
|
const selection = provider.modelSelection;
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
@@ -363,16 +316,18 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
|||||||
} else if (selection.kind === "loading") {
|
} else if (selection.kind === "loading") {
|
||||||
stateNode = (
|
stateNode = (
|
||||||
<View style={styles.rowStateInline}>
|
<View style={styles.rowStateInline}>
|
||||||
<View style={styles.rowSpinner}>
|
<ActivityIndicator
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
size="small"
|
||||||
</View>
|
color={theme.colors.foregroundMuted}
|
||||||
|
style={styles.rowSpinner}
|
||||||
|
/>
|
||||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
stateNode = (
|
stateNode = (
|
||||||
<View style={styles.rowStateInline}>
|
<View style={styles.rowStateInline}>
|
||||||
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -380,11 +335,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{provider.label}</Text>
|
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
{stateNode}
|
{stateNode}
|
||||||
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -480,13 +435,14 @@ function ProviderErrorEmptyState({
|
|||||||
onRetryProvider?: (provider: AgentProvider) => void;
|
onRetryProvider?: (provider: AgentProvider) => void;
|
||||||
isRetryingProvider: boolean;
|
isRetryingProvider: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
onRetryProvider?.(providerId);
|
onRetryProvider?.(providerId);
|
||||||
}, [onRetryProvider, providerId]);
|
}, [onRetryProvider, providerId]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.emptyStateText}>{message}</Text>
|
<Text style={styles.emptyStateText}>{message}</Text>
|
||||||
{onRetryProvider ? (
|
{onRetryProvider ? (
|
||||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||||
@@ -510,6 +466,7 @@ function SelectorContent({
|
|||||||
onRetryProvider,
|
onRetryProvider,
|
||||||
isRetryingProvider,
|
isRetryingProvider,
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||||
const selectedViewProvider = useMemo(
|
const selectedViewProvider = useMemo(
|
||||||
@@ -533,7 +490,7 @@ function SelectorContent({
|
|||||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -546,9 +503,11 @@ function SelectorContent({
|
|||||||
if (drillSelection.kind === "loading") {
|
if (drillSelection.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<View style={styles.rowSpinner}>
|
<ActivityIndicator
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
size="small"
|
||||||
</View>
|
color={theme.colors.foregroundMuted}
|
||||||
|
style={styles.rowSpinner}
|
||||||
|
/>
|
||||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -615,10 +574,8 @@ export function CombinedModelSelector({
|
|||||||
isRetryingProvider = false,
|
isRetryingProvider = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
serverId = null,
|
serverId = null,
|
||||||
desktopPlacement,
|
|
||||||
desktopMinWidth,
|
|
||||||
triggerFill = false,
|
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -674,6 +631,7 @@ export function CombinedModelSelector({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
return resolveSelectedModelLabel({
|
return resolveSelectedModelLabel({
|
||||||
@@ -735,26 +693,14 @@ export function CombinedModelSelector({
|
|||||||
}, [handleOpenChange, isOpen]);
|
}, [handleOpenChange, isOpen]);
|
||||||
|
|
||||||
const triggerStyle = useCallback(
|
const triggerStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
styles.trigger,
|
||||||
// hover/pressed state from the args, so the wrapper must not double-paint.
|
Boolean(hovered) && styles.triggerHovered,
|
||||||
if (triggerFill) {
|
(pressed || isOpen) && styles.triggerPressed,
|
||||||
return [
|
disabled && styles.triggerDisabled,
|
||||||
styles.trigger,
|
renderTrigger ? styles.customTriggerWrapper : null,
|
||||||
styles.customTriggerWrapper,
|
],
|
||||||
styles.triggerFill,
|
[disabled, isOpen, renderTrigger],
|
||||||
disabled && styles.triggerDisabled,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
styles.trigger,
|
|
||||||
Boolean(hovered) && styles.triggerHovered,
|
|
||||||
(pressed || isOpen) && styles.triggerPressed,
|
|
||||||
disabled && styles.triggerDisabled,
|
|
||||||
renderTrigger ? styles.customTriggerWrapper : null,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
[disabled, isOpen, renderTrigger, triggerFill],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBackToAll = useCallback(() => {
|
const handleBackToAll = useCallback(() => {
|
||||||
@@ -780,6 +726,7 @@ export function CombinedModelSelector({
|
|||||||
if (view.kind === "all") {
|
if (view.kind === "all") {
|
||||||
return { title: t("modelSelector.title") };
|
return { title: t("modelSelector.title") };
|
||||||
}
|
}
|
||||||
|
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openProviderSettings}
|
onPress={openProviderSettings}
|
||||||
@@ -792,12 +739,17 @@ export function CombinedModelSelector({
|
|||||||
})}
|
})}
|
||||||
testID={`selector-header-settings-${view.providerId}`}
|
testID={`selector-header-settings-${view.providerId}`}
|
||||||
>
|
>
|
||||||
<HeaderSettingsIcon disabled={!serverId} />
|
<Settings
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
title: view.providerLabel,
|
title: view.providerLabel,
|
||||||
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
|
leading: ProviderIconForView ? (
|
||||||
|
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||||
|
) : undefined,
|
||||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||||
actions: headerActions,
|
actions: headerActions,
|
||||||
search: {
|
search: {
|
||||||
@@ -813,10 +765,15 @@ export function CombinedModelSelector({
|
|||||||
singleProviderView,
|
singleProviderView,
|
||||||
serverId,
|
serverId,
|
||||||
openProviderSettings,
|
openProviderSettings,
|
||||||
|
theme.colors.border,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
handleBackToAll,
|
handleBackToAll,
|
||||||
handleSearchQueryChange,
|
handleSearchQueryChange,
|
||||||
searchResetKey,
|
searchResetKey,
|
||||||
t,
|
t,
|
||||||
|
theme.iconSize.md,
|
||||||
|
theme.iconSize.sm,
|
||||||
|
theme.colors.foreground,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -832,16 +789,12 @@ export function CombinedModelSelector({
|
|||||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
{renderTrigger({
|
||||||
renderTrigger({
|
selectedModelLabel: triggerLabel,
|
||||||
selectedModelLabel: triggerLabel,
|
onPress: handleTriggerPress,
|
||||||
onPress: handleTriggerPress,
|
disabled,
|
||||||
disabled,
|
isOpen,
|
||||||
isOpen,
|
})}
|
||||||
hovered: Boolean(hovered),
|
|
||||||
pressed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<ComboboxTrigger
|
<ComboboxTrigger
|
||||||
@@ -854,8 +807,8 @@ export function CombinedModelSelector({
|
|||||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{hasSelectedProvider ? (
|
{ProviderIcon ? (
|
||||||
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
) : null}
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
@@ -869,8 +822,8 @@ export function CombinedModelSelector({
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement={desktopPlacement}
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={desktopMinWidth}
|
desktopMinWidth={360}
|
||||||
desktopFixedHeight={desktopFixedHeight}
|
desktopFixedHeight={desktopFixedHeight}
|
||||||
header={sheetHeader}
|
header={sheetHeader}
|
||||||
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
||||||
@@ -891,7 +844,7 @@ export function CombinedModelSelector({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<View style={styles.sheetLoadingState}>
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -933,16 +886,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
height: "auto",
|
height: "auto",
|
||||||
},
|
},
|
||||||
// Stretch the wrapper (and, via column + stretch, its single child) to the
|
|
||||||
// full width of the field, with no background or rounding of its own.
|
|
||||||
triggerFill: {
|
|
||||||
alignSelf: "stretch",
|
|
||||||
flexShrink: 0,
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "stretch",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
borderRadius: 0,
|
|
||||||
},
|
|
||||||
favoritesContainer: {
|
favoritesContainer: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
@@ -1063,10 +1006,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
providerIconMuted: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
providerIconForeground: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { GestureDetector } from "react-native-gesture-handler";
|
|||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
|
|||||||
enabled,
|
enabled,
|
||||||
onOpenExplorer,
|
onOpenExplorer,
|
||||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||||
const explorerOpenGesture = useOpenFileExplorerGesture({
|
const explorerOpenGesture = useExplorerOpenGesture({
|
||||||
enabled,
|
enabled,
|
||||||
onOpen: onOpenExplorer,
|
onOpen: onOpenExplorer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function ContextWindowMeter({
|
|||||||
(nextOpen: boolean) => {
|
(nextOpen: boolean) => {
|
||||||
setIsTooltipOpen(nextOpen);
|
setIsTooltipOpen(nextOpen);
|
||||||
if (nextOpen) {
|
if (nextOpen) {
|
||||||
void refreshProviderUsage().catch(() => {});
|
void refreshProviderUsage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshProviderUsage],
|
[refreshProviderUsage],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
|
||||||
interface DiffScrollProps {
|
interface DiffScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -30,7 +30,9 @@ export function DiffScroll({
|
|||||||
const scrollId = useId();
|
const scrollId = useId();
|
||||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||||
|
|
||||||
const closeGestureRef = useFileExplorerCloseGestureRef();
|
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||||
|
const animation = useExplorerSidebarAnimationOptional();
|
||||||
|
const closeGestureRef = animation?.closeGestureRef;
|
||||||
|
|
||||||
// Register/unregister scroll offset tracking
|
// Register/unregister scroll offset tracking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import {
|
|||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
|
||||||
import { HEADER_INNER_HEIGHT } 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";
|
||||||
@@ -90,11 +91,25 @@ export function CompactExplorerSidebar({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
});
|
});
|
||||||
|
const closeTouchStartX = useSharedValue(0);
|
||||||
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||||
|
useSidebarAnimation();
|
||||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||||
mode: "padding",
|
mode: "padding",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
const {
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
overlayVisible,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
closeGestureRef,
|
||||||
|
} = useExplorerSidebarAnimation();
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = useCallback(
|
||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
@@ -107,38 +122,184 @@ export function CompactExplorerSidebar({
|
|||||||
[isOpen, showMobileAgent],
|
[isOpen, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
mobilePanelGestureAnimatingRef.current = true;
|
||||||
|
showMobileAgent();
|
||||||
|
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||||
|
|
||||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||||
|
|
||||||
|
// Swipe gesture to close (swipe right on mobile)
|
||||||
|
const closeGesture = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.withRef(closeGestureRef)
|
||||||
|
.enabled(true)
|
||||||
|
// Use manual activation so child views keep touch streams
|
||||||
|
// unless we detect an intentional right-swipe close.
|
||||||
|
.manualActivation(true)
|
||||||
|
.onTouchesDown((event) => {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (!touch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeTouchStartX.value = touch.absoluteX;
|
||||||
|
closeTouchStartY.value = touch.absoluteY;
|
||||||
|
})
|
||||||
|
.onTouchesMove((event, stateManager) => {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (!touch || event.numberOfTouches !== 1) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||||
|
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||||
|
const absDeltaX = Math.abs(deltaX);
|
||||||
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||||
|
if (deltaX <= -10) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate only on intentional rightward movement.
|
||||||
|
if (deltaX >= 15 && absDeltaX > absDeltaY) {
|
||||||
|
stateManager.activate();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onStart(() => {
|
||||||
|
isGesturing.value = true;
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
// Right sidebar: swipe right to close (positive translationX)
|
||||||
|
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
|
||||||
|
translateX.value = newTranslateX;
|
||||||
|
const progress = 1 - newTranslateX / windowWidth;
|
||||||
|
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
isGesturing.value = false;
|
||||||
|
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
|
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
||||||
|
translationX: event.translationX,
|
||||||
|
velocityX: event.velocityX,
|
||||||
|
shouldClose,
|
||||||
|
windowWidth,
|
||||||
|
});
|
||||||
|
if (shouldClose) {
|
||||||
|
animateToClose();
|
||||||
|
runOnJS(handleCloseFromGesture)();
|
||||||
|
} else {
|
||||||
|
animateToOpen();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
isGesturing.value = false;
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
windowWidth,
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
mobilePanelState,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
handleCloseFromGesture,
|
||||||
|
isGesturing,
|
||||||
|
closeGestureRef,
|
||||||
|
closeTouchStartX,
|
||||||
|
closeTouchStartY,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: translateX.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: backdropOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const backdropCombinedStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
explorerStaticStyles.backdrop,
|
||||||
|
backdropAnimatedStyle,
|
||||||
|
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
||||||
|
// touches it, so a stale animated-prop revert can't wedge an invisible
|
||||||
|
// tap-eating backdrop.
|
||||||
|
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
||||||
|
],
|
||||||
|
[backdropAnimatedStyle, isOpen],
|
||||||
|
);
|
||||||
const mobileSidebarStyle = useMemo(
|
const mobileSidebarStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
explorerStaticStyles.mobileSidebar,
|
||||||
{
|
{
|
||||||
|
width: windowWidth,
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
},
|
},
|
||||||
|
sidebarAnimatedStyle,
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
],
|
],
|
||||||
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
|
[
|
||||||
|
windowWidth,
|
||||||
|
insets.top,
|
||||||
|
theme.colors.surfaceSidebar,
|
||||||
|
sidebarAnimatedStyle,
|
||||||
|
mobileKeyboardInsetStyle,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// display is React-owned on the plain wrapper View (no animated styles), so
|
||||||
|
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
||||||
|
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
||||||
|
const overlayStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
StyleSheet.absoluteFillObject,
|
||||||
|
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
||||||
|
],
|
||||||
|
[overlayVisible],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mobile: full-screen overlay with gesture.
|
||||||
|
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||||
|
let overlayPointerEvents: "auto" | "none" | "box-none";
|
||||||
|
if (!isWeb) overlayPointerEvents = "box-none";
|
||||||
|
else if (isOpen) overlayPointerEvents = "auto";
|
||||||
|
else overlayPointerEvents = "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobilePanelOverlay
|
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||||
panel="file-explorer"
|
<Animated.View style={backdropCombinedStyle} />
|
||||||
closeGesture={closeGesture}
|
|
||||||
panelStyle={mobileSidebarStyle}
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
>
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||||
<ExplorerSidebarContent
|
<ExplorerSidebarContent
|
||||||
activeTab={explorerTab}
|
activeTab={explorerTab}
|
||||||
onTabPress={handleTabPress}
|
onTabPress={handleTabPress}
|
||||||
onClose={handleHeaderClose}
|
onClose={handleHeaderClose}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile
|
isMobile
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</MobilePanelOverlay>
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +604,17 @@ function PrTabContent({
|
|||||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||||
// tries to patch the native node that Reanimated also manages.
|
// tries to patch the native node that Reanimated also manages.
|
||||||
const explorerStaticStyles = RNStyleSheet.create({
|
const explorerStaticStyles = RNStyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
...RNStyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
mobileSidebar: {
|
||||||
|
position: "absolute" as const,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: "hidden" as const,
|
||||||
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import { SvgXml } from "react-native-svg";
|
import { SvgXml } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
@@ -55,6 +55,8 @@ const SORT_OPTIONS: { value: SortOption }[] = [
|
|||||||
{ value: "size" },
|
{ value: "size" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const INDENT_PER_LEVEL = 16;
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`;
|
return `${size} B`;
|
||||||
@@ -127,7 +129,7 @@ function TreeRowItem({
|
|||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.entryRow,
|
styles.entryRow,
|
||||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
|
||||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||||
],
|
],
|
||||||
[depth, isSelected, theme.spacing],
|
[depth, isSelected, theme.spacing],
|
||||||
@@ -141,6 +143,11 @@ function TreeRowItem({
|
|||||||
onDownloadEntry(entry);
|
onDownloadEntry(entry);
|
||||||
}, [onDownloadEntry, entry]);
|
}, [onDownloadEntry, entry]);
|
||||||
|
|
||||||
|
const chevronStyle = useMemo(
|
||||||
|
() => [styles.chevron, isExpanded && styles.chevronExpanded],
|
||||||
|
[isExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
const copyLeading = useMemo(
|
const copyLeading = useMemo(
|
||||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
@@ -152,7 +159,7 @@ function TreeRowItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||||
<TreeIndentGuides depth={depth} />
|
{depth > 0 && Array.from({ length: depth }, (_, i) => <IndentGuide key={i} index={i} />)}
|
||||||
<View style={styles.entryInfo}>
|
<View style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -160,7 +167,11 @@ function TreeRowItem({
|
|||||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||||
}
|
}
|
||||||
if (loading) return <ActivityIndicator size="small" />;
|
if (loading) return <ActivityIndicator size="small" />;
|
||||||
return <TreeChevron expanded={isExpanded} />;
|
return (
|
||||||
|
<View style={chevronStyle}>
|
||||||
|
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.entryName} numberOfLines={1}>
|
<Text style={styles.entryName} numberOfLines={1}>
|
||||||
@@ -1138,6 +1149,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
|
indentGuide: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 1,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
entryInfo: {
|
entryInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1145,6 +1163,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
chevron: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chevronExpanded: {
|
||||||
|
transform: [{ rotate: "90deg" }],
|
||||||
|
},
|
||||||
entryIcon: {
|
entryIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
@@ -1274,3 +1302,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
|
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
|
||||||
|
|
||||||
|
interface IndentGuideProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndentGuide({ index }: IndentGuideProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const guideStyle = useMemo(
|
||||||
|
() => [styles.indentGuide, { left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 }],
|
||||||
|
[index, theme.spacing],
|
||||||
|
);
|
||||||
|
return <View style={guideStyle} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
|
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
|
||||||
import { ChevronDown, Server } from "lucide-react-native";
|
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import {
|
|
||||||
ALL_HOSTS_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
|
||||||
HostPicker,
|
|
||||||
HostStatusDotSlot,
|
|
||||||
} from "@/components/hosts/host-picker";
|
|
||||||
|
|
||||||
const ThemedServer = withUnistyles(Server);
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
||||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
|
|
||||||
export interface HostFilterProps {
|
|
||||||
hosts: HostProfile[];
|
|
||||||
selectedHost: string;
|
|
||||||
onSelectHost: (serverId: string) => void;
|
|
||||||
triggerTestID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "All hosts / <host>" filter pill shared by the History and Schedules
|
|
||||||
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
|
|
||||||
* when only one host exists. Copies the History layout exactly.
|
|
||||||
*/
|
|
||||||
export function HostFilter({
|
|
||||||
hosts,
|
|
||||||
selectedHost,
|
|
||||||
onSelectHost,
|
|
||||||
triggerTestID,
|
|
||||||
}: HostFilterProps): ReactElement {
|
|
||||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
||||||
const filterAnchorRef = useRef<View>(null);
|
|
||||||
|
|
||||||
const selectedHostLabel = useMemo(
|
|
||||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
|
||||||
[hosts, selectedHost],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
|
||||||
|
|
||||||
const filterTriggerStyle = useCallback(
|
|
||||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
||||||
styles.filterTrigger,
|
|
||||||
Boolean(hovered) && styles.filterTriggerHovered,
|
|
||||||
pressed && styles.filterTriggerPressed,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HostPicker
|
|
||||||
hosts={hosts}
|
|
||||||
value={selectedHost}
|
|
||||||
onSelect={onSelectHost}
|
|
||||||
open={isFilterOpen}
|
|
||||||
onOpenChange={setIsFilterOpen}
|
|
||||||
anchorRef={filterAnchorRef}
|
|
||||||
includeAllHost
|
|
||||||
searchable={false}
|
|
||||||
title="Filter by host"
|
|
||||||
desktopPlacement="bottom-start"
|
|
||||||
>
|
|
||||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleFilterOpen}
|
|
||||||
style={filterTriggerStyle}
|
|
||||||
testID={triggerTestID}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
|
||||||
>
|
|
||||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
|
||||||
<ThemedServer size={14} uniProps={mutedColorMapping} />
|
|
||||||
) : (
|
|
||||||
<HostStatusDotSlot serverId={selectedHost} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
|
||||||
{selectedHostLabel}
|
|
||||||
</Text>
|
|
||||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</HostPicker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
filterTriggerWrap: {
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
},
|
|
||||||
filterTrigger: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1.5],
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
paddingVertical: theme.spacing[1.5],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
filterTriggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
filterTriggerPressed: {
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
},
|
|
||||||
filterTriggerText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||||
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
|
|
||||||
|
|
||||||
export function getHostPickerLabel(
|
export function getHostPickerLabel(
|
||||||
hosts: Array<{ label: string; serverId: string }>,
|
hosts: Array<{ label: string; serverId: string }>,
|
||||||
|
|||||||
@@ -11,16 +11,10 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
|
|||||||
import {
|
import {
|
||||||
ADD_HOST_OPTION_ID,
|
ADD_HOST_OPTION_ID,
|
||||||
ALL_HOSTS_OPTION_ID,
|
ALL_HOSTS_OPTION_ID,
|
||||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
getHostPickerLabel,
|
||||||
} from "./host-picker-constants";
|
} from "./host-picker-constants";
|
||||||
|
|
||||||
export {
|
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
||||||
ADD_HOST_OPTION_ID,
|
|
||||||
ALL_HOSTS_OPTION_ID,
|
|
||||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEARCHABLE_THRESHOLD = 10;
|
const SEARCHABLE_THRESHOLD = 10;
|
||||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||||
@@ -121,12 +115,6 @@ export function HostPickerOption({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
|
|
||||||
add: "Add host",
|
|
||||||
all: "All hosts",
|
|
||||||
enableBuiltInDaemon: "Enable built-in daemon",
|
|
||||||
};
|
|
||||||
|
|
||||||
function SystemHostPickerOption({
|
function SystemHostPickerOption({
|
||||||
active,
|
active,
|
||||||
selected,
|
selected,
|
||||||
@@ -137,12 +125,12 @@ function SystemHostPickerOption({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
kind: "add" | "all" | "enableBuiltInDaemon";
|
kind: "add" | "all";
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const Icon = kind === "add" ? Plus : Server;
|
const Icon = kind === "add" ? Plus : Server;
|
||||||
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
|
const label = kind === "add" ? "Add host" : "All hosts";
|
||||||
const leadingSlot = useMemo(
|
const leadingSlot = useMemo(
|
||||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||||
@@ -170,13 +158,11 @@ export interface HostPickerProps {
|
|||||||
includeAllHost?: boolean;
|
includeAllHost?: boolean;
|
||||||
includeAddHost?: boolean;
|
includeAddHost?: boolean;
|
||||||
onAddHost?: () => void;
|
onAddHost?: () => void;
|
||||||
includeEnableBuiltInDaemon?: boolean;
|
|
||||||
onEnableBuiltInDaemon?: () => void;
|
|
||||||
showActiveConnection?: boolean;
|
showActiveConnection?: boolean;
|
||||||
onOpenHostSettings?: (serverId: string) => void;
|
onOpenHostSettings?: (serverId: string) => void;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
desktopMinWidth?: number;
|
desktopMinWidth?: number;
|
||||||
addHostTestID?: string;
|
addHostTestID?: string;
|
||||||
hostOptionTestID?: (serverId: string) => string;
|
hostOptionTestID?: (serverId: string) => string;
|
||||||
@@ -193,13 +179,11 @@ export function HostPicker({
|
|||||||
includeAllHost,
|
includeAllHost,
|
||||||
includeAddHost,
|
includeAddHost,
|
||||||
onAddHost,
|
onAddHost,
|
||||||
includeEnableBuiltInDaemon,
|
|
||||||
onEnableBuiltInDaemon,
|
|
||||||
showActiveConnection,
|
showActiveConnection,
|
||||||
onOpenHostSettings,
|
onOpenHostSettings,
|
||||||
searchable,
|
searchable,
|
||||||
title,
|
title,
|
||||||
desktopPlacement = "bottom-start",
|
desktopPlacement = "top-start",
|
||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
addHostTestID,
|
addHostTestID,
|
||||||
hostOptionTestID,
|
hostOptionTestID,
|
||||||
@@ -215,13 +199,8 @@ export function HostPicker({
|
|||||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||||
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
||||||
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
||||||
if (includeEnableBuiltInDaemon)
|
|
||||||
hostOptions.push({
|
|
||||||
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
|
||||||
label: "Enable built-in daemon",
|
|
||||||
});
|
|
||||||
return hostOptions;
|
return hostOptions;
|
||||||
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
|
}, [orderedHosts, includeAllHost, includeAddHost]);
|
||||||
|
|
||||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||||
|
|
||||||
@@ -229,14 +208,12 @@ export function HostPicker({
|
|||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (id === ADD_HOST_OPTION_ID) {
|
if (id === ADD_HOST_OPTION_ID) {
|
||||||
onAddHost?.();
|
onAddHost?.();
|
||||||
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
|
||||||
onEnableBuiltInDaemon?.();
|
|
||||||
} else {
|
} else {
|
||||||
onSelect(id);
|
onSelect(id);
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
|
[onAddHost, onOpenChange, onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenHostSettings = useCallback(
|
const handleOpenHostSettings = useCallback(
|
||||||
@@ -269,11 +246,6 @@ export function HostPicker({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
|
||||||
return (
|
|
||||||
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<HostPickerOption
|
<HostPickerOption
|
||||||
serverId={option.id}
|
serverId={option.id}
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import {
|
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
|
||||||
CalendarClock,
|
|
||||||
FolderPlus,
|
|
||||||
History,
|
|
||||||
Home,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Server,
|
|
||||||
Settings,
|
|
||||||
X,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +11,13 @@ import {
|
|||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
import Animated, {
|
||||||
|
Extrapolation,
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
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 { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
@@ -32,14 +28,19 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import { type SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import {
|
||||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
type SidebarProjectEntry,
|
||||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
type SidebarStatusWorkspacePlacement,
|
||||||
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
useSidebarWorkspacesList,
|
||||||
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||||
|
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -51,12 +52,10 @@ import {
|
|||||||
usePanelStore,
|
usePanelStore,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
|
||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
buildNewWorkspaceRoute,
|
buildNewWorkspaceRoute,
|
||||||
buildSchedulesRoute,
|
|
||||||
buildSessionsRoute,
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
buildSettingsAddHostRoute,
|
||||||
buildSettingsHostSectionRoute,
|
buildSettingsHostSectionRoute,
|
||||||
@@ -69,20 +68,25 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
|||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
|
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
|
interface LeftSidebarProps {
|
||||||
|
selectedAgentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarSharedProps {
|
interface SidebarSharedProps {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
statusGroups: StatusGroup[];
|
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
isInitialLoad: boolean;
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean;
|
isRevalidating: boolean;
|
||||||
isManualRefresh: boolean;
|
isManualRefresh: boolean;
|
||||||
groupMode: SidebarGroupMode;
|
groupMode: SidebarGroupMode;
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||||
toggleProjectCollapsed: (projectKey: string) => void;
|
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||||
handleRefresh: () => void;
|
handleRefresh: () => void;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleHome: () => void;
|
handleHome: () => void;
|
||||||
@@ -101,26 +105,28 @@ interface SidebarLabels {
|
|||||||
switchHost: string;
|
switchHost: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
schedules: string;
|
|
||||||
closeSidebar: string;
|
closeSidebar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
insetsBottom: number;
|
insetsBottom: number;
|
||||||
|
isOpen: boolean;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
handleViewMoreNavigate: () => void;
|
handleViewMoreNavigate: () => void;
|
||||||
handleViewSchedulesNavigate: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleViewMore: () => void;
|
handleViewMore: () => void;
|
||||||
handleViewSchedules: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar() {
|
export const LeftSidebar = memo(function LeftSidebar({
|
||||||
|
selectedAgentId: _selectedAgentId,
|
||||||
|
}: LeftSidebarProps) {
|
||||||
|
void _selectedAgentId;
|
||||||
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -131,18 +137,22 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
workspacePlacements,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
statusGroups,
|
} = useSidebarWorkspacesList({
|
||||||
collapsedProjectKeys,
|
enabled: isCompactLayout || isOpen,
|
||||||
toggleProjectCollapsed,
|
});
|
||||||
groupMode,
|
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||||
shortcutModel,
|
placements: workspacePlacements,
|
||||||
} = useSidebarModel();
|
});
|
||||||
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
|
useSidebarShortcutModel({ projects });
|
||||||
|
|
||||||
|
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -211,10 +221,6 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
router.push(buildSessionsRoute());
|
router.push(buildSessionsRoute());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewSchedulesNavigate = useCallback(() => {
|
|
||||||
router.push(buildSchedulesRoute());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||||
const labels = useMemo(
|
const labels = useMemo(
|
||||||
(): SidebarLabels => ({
|
(): SidebarLabels => ({
|
||||||
@@ -225,7 +231,6 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
switchHost: t("sidebar.host.switchTitle"),
|
switchHost: t("sidebar.host.switchTitle"),
|
||||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||||
sessions: t("sidebar.sections.sessions"),
|
sessions: t("sidebar.sections.sessions"),
|
||||||
schedules: t("sidebar.sections.schedules"),
|
|
||||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
@@ -233,7 +238,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
|
|
||||||
const sharedProps = {
|
const sharedProps = {
|
||||||
theme,
|
theme,
|
||||||
statusGroups,
|
statusWorkspacePlacements,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -254,6 +259,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
|
isOpen={isOpen}
|
||||||
closeSidebar={showMobileAgent}
|
closeSidebar={showMobileAgent}
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleHome={handleHomeMobile}
|
handleHome={handleHomeMobile}
|
||||||
@@ -261,7 +267,6 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
handleAddHost={handleAddHostMobile}
|
handleAddHost={handleAddHostMobile}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -277,7 +282,6 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
handleAddHost={handleAddHostDesktop}
|
handleAddHost={handleAddHostDesktop}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||||
handleViewMore={handleViewMoreNavigate}
|
handleViewMore={handleViewMoreNavigate}
|
||||||
handleViewSchedules={handleViewSchedulesNavigate}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -360,7 +364,6 @@ function SidebarHostPicker({
|
|||||||
showActiveConnection
|
showActiveConnection
|
||||||
onOpenHostSettings={onOpenHostSettings}
|
onOpenHostSettings={onOpenHostSettings}
|
||||||
searchable
|
searchable
|
||||||
desktopPlacement="top-start"
|
|
||||||
desktopMinWidth={240}
|
desktopMinWidth={240}
|
||||||
addHostTestID="sidebar-host-add"
|
addHostTestID="sidebar-host-add"
|
||||||
hostOptionTestID={sidebarHostOptionTestID}
|
hostOptionTestID={sidebarHostOptionTestID}
|
||||||
@@ -531,7 +534,7 @@ function SidebarFooter({
|
|||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
theme,
|
theme,
|
||||||
statusGroups,
|
statusWorkspacePlacements,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -551,125 +554,259 @@ function MobileSidebar({
|
|||||||
handleOpenHostSettings,
|
handleOpenHostSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
insetsBottom,
|
insetsBottom,
|
||||||
|
isOpen,
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
handleViewSchedulesNavigate,
|
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
const {
|
||||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
overlayVisible,
|
||||||
|
isGesturing,
|
||||||
|
mobilePanelState,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
closeGestureRef,
|
||||||
|
} = useSidebarAnimation();
|
||||||
|
const closeTouchStartX = useSharedValue(0);
|
||||||
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
|
||||||
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
closeSidebar();
|
||||||
|
}, [closeSidebar, gestureAnimatingRef]);
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
|
translateX.value = -windowWidth;
|
||||||
|
backdropOpacity.value = 0;
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
handleViewMoreNavigate();
|
handleViewMoreNavigate();
|
||||||
}, [closeSidebar, handleViewMoreNavigate]);
|
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||||
|
|
||||||
const handleViewSchedules = useCallback(() => {
|
|
||||||
closeSidebar();
|
|
||||||
handleViewSchedulesNavigate();
|
|
||||||
}, [closeSidebar, handleViewSchedulesNavigate]);
|
|
||||||
|
|
||||||
const handleWorkspacePress = useCallback(() => {
|
const handleWorkspacePress = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}, [closeSidebar]);
|
}, [closeSidebar]);
|
||||||
|
|
||||||
|
const closeGesture = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.withRef(closeGestureRef)
|
||||||
|
.enabled(true)
|
||||||
|
.manualActivation(true)
|
||||||
|
.onTouchesDown((event) => {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (!touch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeTouchStartX.value = touch.absoluteX;
|
||||||
|
closeTouchStartY.value = touch.absoluteY;
|
||||||
|
})
|
||||||
|
.onTouchesMove((event, stateManager) => {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (!touch || event.numberOfTouches !== 1) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||||
|
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||||
|
const absDeltaX = Math.abs(deltaX);
|
||||||
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaX >= 10) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||||
|
stateManager.activate();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onStart(() => {
|
||||||
|
isGesturing.value = true;
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
||||||
|
translateX.value = newTranslateX;
|
||||||
|
backdropOpacity.value = interpolate(
|
||||||
|
newTranslateX,
|
||||||
|
[-windowWidth, 0],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
isGesturing.value = false;
|
||||||
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||||
|
if (shouldClose) {
|
||||||
|
animateToClose();
|
||||||
|
runOnJS(handleCloseFromGesture)();
|
||||||
|
} else {
|
||||||
|
animateToOpen();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
isGesturing.value = false;
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
closeGestureRef,
|
||||||
|
closeTouchStartX,
|
||||||
|
closeTouchStartY,
|
||||||
|
isGesturing,
|
||||||
|
mobilePanelState,
|
||||||
|
windowWidth,
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
animateToClose,
|
||||||
|
animateToOpen,
|
||||||
|
handleCloseFromGesture,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const mobileSidebarInsetStyle = useMemo(
|
const mobileSidebarInsetStyle = useMemo(
|
||||||
() => ({
|
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
||||||
paddingTop: insetsTop,
|
[windowWidth, insetsTop, insetsBottom],
|
||||||
paddingBottom: insetsBottom,
|
);
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
}),
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
transform: [{ translateX: translateX.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: backdropOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let overlayPointerEvents: "auto" | "none" | "box-none";
|
||||||
|
if (!isWeb) overlayPointerEvents = "box-none";
|
||||||
|
else if (isOpen) overlayPointerEvents = "auto";
|
||||||
|
else overlayPointerEvents = "none";
|
||||||
|
|
||||||
|
const backdropStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
staticStyles.backdrop,
|
||||||
|
backdropAnimatedStyle,
|
||||||
|
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
||||||
|
// touches it, so a stale animated-prop revert can't wedge an invisible
|
||||||
|
// tap-eating backdrop.
|
||||||
|
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
||||||
|
],
|
||||||
|
[backdropAnimatedStyle, isOpen],
|
||||||
|
);
|
||||||
|
const mobileSidebarStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
staticStyles.mobileSidebar,
|
||||||
|
mobileSidebarInsetStyle,
|
||||||
|
sidebarAnimatedStyle,
|
||||||
|
{ backgroundColor: theme.colors.surfaceSidebar },
|
||||||
|
],
|
||||||
|
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
||||||
|
);
|
||||||
|
// display is React-owned on the plain wrapper View (no animated styles), so
|
||||||
|
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
||||||
|
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
||||||
|
const overlayStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
StyleSheet.absoluteFillObject,
|
||||||
|
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
||||||
|
],
|
||||||
|
[overlayVisible],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobilePanelOverlay
|
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||||
panel="agent-list"
|
<Animated.View style={backdropStyle} />
|
||||||
closeGesture={closeGesture}
|
|
||||||
panelStyle={mobileSidebarInsetStyle}
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
>
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<View style={styles.sidebarHeaderGroup}>
|
<View style={styles.sidebarHeaderGroup}>
|
||||||
<SidebarNewWorkspaceHeaderRow
|
<SidebarNewWorkspaceHeaderRow
|
||||||
label={labels.newWorkspace}
|
label={labels.newWorkspace}
|
||||||
testID="sidebar-global-new-workspace"
|
testID="sidebar-global-new-workspace"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
shortcutKeys={newWorkspaceKeys}
|
shortcutKeys={newWorkspaceKeys}
|
||||||
onBeforeNavigate={closeSidebar}
|
onBeforeNavigate={closeSidebar}
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
<SidebarHeaderRow
|
||||||
icon={History}
|
icon={History}
|
||||||
label={labels.sessions}
|
label={labels.sessions}
|
||||||
onPress={handleViewMore}
|
onPress={handleViewMore}
|
||||||
isActive={isSessionsActive}
|
isActive={isSessionsActive}
|
||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
</View>
|
||||||
icon={CalendarClock}
|
<WorkspacesSectionHeader />
|
||||||
label={labels.schedules}
|
<Pressable
|
||||||
onPress={handleViewSchedules}
|
style={styles.mobileCloseButton}
|
||||||
isActive={isSchedulesActive}
|
onPress={closeSidebar}
|
||||||
testID="sidebar-schedules"
|
testID="sidebar-close"
|
||||||
variant="compact"
|
nativeID="sidebar-close"
|
||||||
/>
|
accessible
|
||||||
</View>
|
accessibilityRole="button"
|
||||||
<WorkspacesSectionHeader />
|
accessibilityLabel={labels.closeSidebar}
|
||||||
<Pressable
|
hitSlop={8}
|
||||||
style={styles.mobileCloseButton}
|
>
|
||||||
onPress={closeSidebar}
|
{({ hovered, pressed }) => (
|
||||||
testID="sidebar-close"
|
<X
|
||||||
nativeID="sidebar-close"
|
size={theme.iconSize.md}
|
||||||
accessible
|
color={
|
||||||
accessibilityRole="button"
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||||
accessibilityLabel={labels.closeSidebar}
|
}
|
||||||
hitSlop={8}
|
/>
|
||||||
>
|
)}
|
||||||
{({ hovered, pressed }) => (
|
</Pressable>
|
||||||
<X
|
|
||||||
size={theme.iconSize.md}
|
{isInitialLoad ? (
|
||||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
<SidebarAgentListSkeleton />
|
||||||
|
) : (
|
||||||
|
<SidebarWorkspaceList
|
||||||
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
|
groupMode={groupMode}
|
||||||
|
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||||
|
projects={projects}
|
||||||
|
projectNamesByKey={projectNamesByKey}
|
||||||
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onWorkspacePress={handleWorkspacePress}
|
||||||
|
onAddProject={handleOpenProject}
|
||||||
|
parentGestureRef={closeGestureRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SidebarFooter
|
||||||
|
theme={theme}
|
||||||
|
handleOpenProject={handleOpenProject}
|
||||||
|
handleHome={handleHome}
|
||||||
|
handleSettings={handleSettings}
|
||||||
|
labels={labels}
|
||||||
|
handleAddHost={handleAddHost}
|
||||||
|
handleOpenHostSettings={handleOpenHostSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
</View>
|
||||||
</Pressable>
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
{isInitialLoad ? (
|
</View>
|
||||||
<SidebarAgentListSkeleton />
|
|
||||||
) : (
|
|
||||||
<SidebarWorkspaceList
|
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
||||||
groupMode={groupMode}
|
|
||||||
statusGroups={statusGroups}
|
|
||||||
projects={projects}
|
|
||||||
projectNamesByKey={projectNamesByKey}
|
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
onWorkspacePress={handleWorkspacePress}
|
|
||||||
onAddProject={handleOpenProject}
|
|
||||||
parentGestureRef={closeGestureRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SidebarFooter
|
|
||||||
theme={theme}
|
|
||||||
handleOpenProject={handleOpenProject}
|
|
||||||
handleHome={handleHome}
|
|
||||||
handleSettings={handleSettings}
|
|
||||||
labels={labels}
|
|
||||||
handleAddHost={handleAddHost}
|
|
||||||
handleOpenHostSettings={handleOpenHostSettings}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</MobilePanelOverlay>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopSidebar({
|
function DesktopSidebar({
|
||||||
theme,
|
theme,
|
||||||
statusGroups,
|
statusWorkspacePlacements,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -690,11 +827,9 @@ function DesktopSidebar({
|
|||||||
insetsTop,
|
insetsTop,
|
||||||
isOpen,
|
isOpen,
|
||||||
handleViewMore,
|
handleViewMore,
|
||||||
handleViewSchedules,
|
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -774,14 +909,6 @@ function DesktopSidebar({
|
|||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={CalendarClock}
|
|
||||||
label={labels.schedules}
|
|
||||||
onPress={handleViewSchedules}
|
|
||||||
isActive={isSchedulesActive}
|
|
||||||
testID="sidebar-schedules"
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<WorkspacesSectionHeader />
|
<WorkspacesSectionHeader />
|
||||||
@@ -794,7 +921,7 @@ function DesktopSidebar({
|
|||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
groupMode={groupMode}
|
groupMode={groupMode}
|
||||||
statusGroups={statusGroups}
|
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
@@ -883,6 +1010,17 @@ function WorkspacesSectionHeader() {
|
|||||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||||
// tries to patch the native node that Reanimated also manages.
|
// tries to patch the native node that Reanimated also manages.
|
||||||
const staticStyles = RNStyleSheet.create({
|
const staticStyles = RNStyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
...RNStyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
mobileSidebar: {
|
||||||
|
position: "absolute" as const,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: "hidden" as const,
|
||||||
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3038,8 +3038,6 @@ interface ToolCallProps {
|
|||||||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||||
onOpenFilePath?: (filePath: string) => void;
|
onOpenFilePath?: (filePath: string) => void;
|
||||||
defaultExpanded?: boolean;
|
|
||||||
forceInline?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
@@ -3056,14 +3054,11 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
onInlineDetailsHoverChange,
|
onInlineDetailsHoverChange,
|
||||||
onInlineDetailsExpandedChange,
|
onInlineDetailsExpandedChange,
|
||||||
onOpenFilePath,
|
onOpenFilePath,
|
||||||
defaultExpanded,
|
|
||||||
forceInline = false,
|
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { openToolCall } = useToolCallSheet();
|
const { openToolCall } = useToolCallSheet();
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const shouldRenderInline = !isMobile || forceInline;
|
|
||||||
|
|
||||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
@@ -3101,7 +3096,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
}, [presentation.openFilePath, onOpenFilePath]);
|
}, [presentation.openFilePath, onOpenFilePath]);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!shouldRenderInline) {
|
if (isMobile) {
|
||||||
openToolCall({
|
openToolCall({
|
||||||
displayName: presentation.displayName,
|
displayName: presentation.displayName,
|
||||||
summary: presentation.summary,
|
summary: presentation.summary,
|
||||||
@@ -3114,7 +3109,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
setIsExpanded((prev) => !prev);
|
setIsExpanded((prev) => !prev);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
shouldRenderInline,
|
isMobile,
|
||||||
openToolCall,
|
openToolCall,
|
||||||
presentation.displayName,
|
presentation.displayName,
|
||||||
presentation.summary,
|
presentation.summary,
|
||||||
@@ -3125,22 +3120,22 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
|
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onInlineDetailsHoverChange(false);
|
onInlineDetailsHoverChange(false);
|
||||||
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
|
}, [isExpanded, isMobile, onInlineDetailsHoverChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsExpandedChange) {
|
if (!onInlineDetailsExpandedChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shouldRenderInline) {
|
if (isMobile) {
|
||||||
onInlineDetailsExpandedChange(false);
|
onInlineDetailsExpandedChange(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onInlineDetailsExpandedChange(isExpanded);
|
onInlineDetailsExpandedChange(isExpanded);
|
||||||
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
|
}, [isExpanded, isMobile, onInlineDetailsExpandedChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsExpandedChange) {
|
if (!onInlineDetailsExpandedChange) {
|
||||||
@@ -3153,7 +3148,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
|
|
||||||
// Render inline details for desktop
|
// Render inline details for desktop
|
||||||
const renderDetails = useCallback(() => {
|
const renderDetails = useCallback(() => {
|
||||||
if (!shouldRenderInline) return null;
|
if (isMobile) return null;
|
||||||
return (
|
return (
|
||||||
<ToolCallDetailsContent
|
<ToolCallDetailsContent
|
||||||
detail={effectiveDetail}
|
detail={effectiveDetail}
|
||||||
@@ -3162,7 +3157,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||||
|
|
||||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||||
return (
|
return (
|
||||||
@@ -3180,10 +3175,10 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
label={presentation.displayName}
|
label={presentation.displayName}
|
||||||
secondaryLabel={presentation.summary}
|
secondaryLabel={presentation.summary}
|
||||||
icon={presentation.icon}
|
icon={presentation.icon}
|
||||||
isExpanded={shouldRenderInline && isExpanded}
|
isExpanded={!isMobile && isExpanded}
|
||||||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||||||
onOpenFile={handleOpenFile}
|
onOpenFile={handleOpenFile}
|
||||||
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
|
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||||
isLoading={status === "running" || status === "executing"}
|
isLoading={status === "running" || status === "executing"}
|
||||||
isError={status === "failed"}
|
isError={status === "failed"}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
@@ -3205,7 +3200,5 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
|||||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||||
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
||||||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
|
||||||
if (previous.forceInline !== next.forceInline) return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FolderOpen } from "lucide-react-native";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { pickDirectory } from "@/desktop/pick-directory";
|
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|
||||||
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
|
||||||
|
|
||||||
export function ProjectPickerBrowseButton({
|
|
||||||
serverId,
|
|
||||||
disabled,
|
|
||||||
onSelect,
|
|
||||||
onError,
|
|
||||||
}: ProjectPickerBrowseButtonProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const path = await pickDirectory();
|
|
||||||
if (path) {
|
|
||||||
onSelect(path);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
onError();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [onError, onSelect]);
|
|
||||||
|
|
||||||
if (!isLocalDaemon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={FolderOpen}
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={handlePress}
|
|
||||||
>
|
|
||||||
{t("projectPicker.browse")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
|
||||||
|
|
||||||
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface ProjectPickerBrowseButtonProps {
|
|
||||||
serverId: string;
|
|
||||||
disabled: boolean;
|
|
||||||
onSelect: (path: string) => void;
|
|
||||||
onError: () => void;
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import { useProjectPickerStore } from "@/stores/project-picker-store";
|
|||||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
|
|
||||||
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
||||||
|
|
||||||
interface PathRowProps {
|
interface PathRowProps {
|
||||||
@@ -156,36 +155,31 @@ export function ProjectPickerModal() {
|
|||||||
const directorySuggestionsQuery = useQuery({
|
const directorySuggestionsQuery = useQuery({
|
||||||
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) return [];
|
||||||
return { query: debouncedQuery, paths: [] };
|
|
||||||
}
|
|
||||||
const result = await client.getDirectorySuggestions({
|
const result = await client.getDirectorySuggestions({
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
includeDirectories: true,
|
includeDirectories: true,
|
||||||
includeFiles: false,
|
includeFiles: false,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
});
|
||||||
return {
|
return (
|
||||||
query: debouncedQuery,
|
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
|
||||||
paths:
|
);
|
||||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
|
|
||||||
[],
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(client) && isConnected && open,
|
enabled: Boolean(client) && isConnected && open,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(
|
||||||
const currentSuggestions =
|
() =>
|
||||||
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
|
buildProjectPickerOptions({
|
||||||
return buildProjectPickerOptions({
|
recommendedPaths,
|
||||||
recommendedPaths,
|
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||||
serverPaths: currentSuggestions?.paths ?? [],
|
query,
|
||||||
query,
|
}),
|
||||||
});
|
[directorySuggestionsQuery.data, query, recommendedPaths],
|
||||||
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
|
);
|
||||||
const hasQuery = query.trim().length > 0;
|
const hasQuery = query.trim().length > 0;
|
||||||
const isSearching =
|
const isSearching =
|
||||||
hasQuery &&
|
hasQuery &&
|
||||||
@@ -219,8 +213,6 @@ export function ProjectPickerModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOpenErrorReason(getOpenProjectFailureReason(result));
|
setOpenErrorReason(getOpenProjectFailureReason(result));
|
||||||
} catch {
|
|
||||||
setOpenErrorReason("open_failed");
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -240,10 +232,6 @@ export function ProjectPickerModal() {
|
|||||||
setOpenErrorReason(null);
|
setOpenErrorReason(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBrowseError = useCallback(() => {
|
|
||||||
setOpenErrorReason("open_failed");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
@@ -274,7 +262,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
function handler(event: KeyboardEvent) {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
|
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
|
||||||
|
|
||||||
if (key === "Escape") {
|
if (key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -282,6 +270,12 @@ export function ProjectPickerModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
submitActiveOption();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||||
if (options.length === 0) return;
|
if (options.length === 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -297,7 +291,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handler, true);
|
window.addEventListener("keydown", handler, true);
|
||||||
return () => window.removeEventListener("keydown", handler, true);
|
return () => window.removeEventListener("keydown", handler, true);
|
||||||
}, [close, open, options.length]);
|
}, [close, open, options.length, submitActiveOption]);
|
||||||
|
|
||||||
const panelStyle = useMemo(
|
const panelStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -336,7 +330,6 @@ export function ProjectPickerModal() {
|
|||||||
<View style={panelStyle}>
|
<View style={panelStyle}>
|
||||||
<View style={headerStyle}>
|
<View style={headerStyle}>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="project-picker-input"
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={handleChangeQuery}
|
onChangeText={handleChangeQuery}
|
||||||
@@ -350,12 +343,6 @@ export function ProjectPickerModal() {
|
|||||||
returnKeyType="go"
|
returnKeyType="go"
|
||||||
onSubmitEditing={submitActiveOption}
|
onSubmitEditing={submitActiveOption}
|
||||||
/>
|
/>
|
||||||
<ProjectPickerBrowseButton
|
|
||||||
serverId={serverId}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onSelect={handleSelectPath}
|
|
||||||
onError={handleBrowseError}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ProjectPickerResults
|
<ProjectPickerResults
|
||||||
@@ -396,15 +383,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
...theme.shadow.lg,
|
...theme.shadow.lg,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
|
|||||||
it("puts an absolute path row first", () => {
|
it("puts an absolute path row first", () => {
|
||||||
const options = buildProjectPickerOptions({
|
const options = buildProjectPickerOptions({
|
||||||
recommendedPaths: ["/repo/api"],
|
recommendedPaths: ["/repo/api"],
|
||||||
serverPaths: ["/repo/api"],
|
serverPaths: [],
|
||||||
query: "/repo",
|
query: "/repo",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
|
|||||||
it("puts a home-relative path row first", () => {
|
it("puts a home-relative path row first", () => {
|
||||||
const options = buildProjectPickerOptions({
|
const options = buildProjectPickerOptions({
|
||||||
recommendedPaths: ["/Users/mo/src/api"],
|
recommendedPaths: ["/Users/mo/src/api"],
|
||||||
serverPaths: ["/Users/mo/src/api"],
|
serverPaths: [],
|
||||||
query: "~/src",
|
query: "~/src",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ describe("resolveProviderIconName", () => {
|
|||||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||||
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the bot icon for unknown custom providers", () => {
|
it("falls back to the bot icon for unknown custom providers", () => {
|
||||||
|
|||||||
@@ -92,49 +92,30 @@ function QuestionOptionRow({
|
|||||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||||
|
|
||||||
// Static left-side control: square for multi-select, circle for single-select.
|
|
||||||
// Always rendered so toggling only swaps fill/border — the row never reflows.
|
|
||||||
const controlStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.selectionControl,
|
|
||||||
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
|
|
||||||
{
|
|
||||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
|
|
||||||
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
|
|
||||||
);
|
|
||||||
const radioDotStyle = useMemo(
|
|
||||||
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
|
|
||||||
[theme.colors.accent],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
accessibilityRole={multiSelect ? "checkbox" : "radio"}
|
accessibilityRole="button"
|
||||||
accessibilityLabel={option.label}
|
accessibilityLabel={option.label}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-checked={isSelected}
|
aria-selected={isSelected}
|
||||||
>
|
>
|
||||||
<View style={styles.optionItemContent}>
|
<View style={styles.optionItemContent}>
|
||||||
<View style={controlStyle}>
|
|
||||||
{isSelected && multiSelect ? (
|
|
||||||
<Check size={12} color={theme.colors.accentForeground} />
|
|
||||||
) : null}
|
|
||||||
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.optionTextBlock}>
|
<View style={styles.optionTextBlock}>
|
||||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||||
{option.description ? (
|
{option.description ? (
|
||||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
{isSelected ? (
|
||||||
|
<View style={styles.optionCheckSlot}>
|
||||||
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -143,9 +124,7 @@ function QuestionOptionRow({
|
|||||||
interface QuestionNavButtonProps {
|
interface QuestionNavButtonProps {
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
header: string;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isAnswered: boolean;
|
|
||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
onSelect: (index: number) => void;
|
onSelect: (index: number) => void;
|
||||||
}
|
}
|
||||||
@@ -153,9 +132,7 @@ interface QuestionNavButtonProps {
|
|||||||
function QuestionNavButton({
|
function QuestionNavButton({
|
||||||
index,
|
index,
|
||||||
total,
|
total,
|
||||||
header,
|
|
||||||
isActive,
|
isActive,
|
||||||
isAnswered,
|
|
||||||
isResponding,
|
isResponding,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: QuestionNavButtonProps) {
|
}: QuestionNavButtonProps) {
|
||||||
@@ -194,7 +171,7 @@ function QuestionNavButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="tab"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
@@ -203,61 +180,11 @@ function QuestionNavButton({
|
|||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
>
|
>
|
||||||
{isAnswered ? (
|
<Text style={textStyle}>{index + 1}</Text>
|
||||||
<Check
|
|
||||||
size={12}
|
|
||||||
color={isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Text style={textStyle} numberOfLines={1}>
|
|
||||||
{header}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuestionNavProps {
|
|
||||||
questions: QuestionFormQuestion[];
|
|
||||||
activeIndex: number;
|
|
||||||
isAnswered: (qIndex: number) => boolean;
|
|
||||||
isResponding: boolean;
|
|
||||||
onSelect: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Titled tabs (one per question header) with a check on answered ones. Hidden for
|
|
||||||
// a lone question — a single "1 of 1" tab carries no information.
|
|
||||||
function QuestionNav({
|
|
||||||
questions,
|
|
||||||
activeIndex,
|
|
||||||
isAnswered,
|
|
||||||
isResponding,
|
|
||||||
onSelect,
|
|
||||||
}: QuestionNavProps) {
|
|
||||||
if (questions.length <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={styles.questionNav}
|
|
||||||
testID="question-form-question-nav"
|
|
||||||
accessibilityRole="tablist"
|
|
||||||
>
|
|
||||||
{questions.map((question, qIndex) => (
|
|
||||||
<QuestionNavButton
|
|
||||||
key={question.header}
|
|
||||||
index={qIndex}
|
|
||||||
total={questions.length}
|
|
||||||
header={question.header}
|
|
||||||
isActive={qIndex === activeIndex}
|
|
||||||
isAnswered={isAnswered(qIndex)}
|
|
||||||
isResponding={isResponding}
|
|
||||||
onSelect={onSelect}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuestionOtherInputProps {
|
interface QuestionOtherInputProps {
|
||||||
qIndex: number;
|
qIndex: number;
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
@@ -428,12 +355,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
setActiveQuestionIndex(index);
|
setActiveQuestionIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navIsAnswered = useCallback(
|
|
||||||
(qIndex: number) =>
|
|
||||||
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
|
|
||||||
[questions, selections, otherTexts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePrimaryAction = useCallback(() => {
|
const handlePrimaryAction = useCallback(() => {
|
||||||
if (!isLastQuestion) {
|
if (!isLastQuestion) {
|
||||||
if (!activeQuestionAnswered || isResponding) return;
|
if (!activeQuestionAnswered || isResponding) return;
|
||||||
@@ -486,16 +407,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||||
[theme.colors.foreground],
|
[theme.colors.foreground],
|
||||||
);
|
);
|
||||||
// Single-select radios need a group; checkboxes are valid standalone.
|
const questionNavStyle = useMemo(
|
||||||
const optionsGroupAccessibility = useMemo(
|
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||||
() =>
|
[isMobile],
|
||||||
activeQuestion && !activeQuestion.multiSelect
|
|
||||||
? ({
|
|
||||||
accessibilityRole: "radiogroup",
|
|
||||||
accessibilityLabel: activeQuestion.question,
|
|
||||||
} as const)
|
|
||||||
: {},
|
|
||||||
[activeQuestion],
|
|
||||||
);
|
);
|
||||||
const actionsContainerStyle = useMemo(
|
const actionsContainerStyle = useMemo(
|
||||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||||
@@ -522,23 +436,33 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle} testID="question-form-card">
|
<View style={containerStyle} testID="question-form-card">
|
||||||
<QuestionNav
|
<View style={styles.questionTopRow}>
|
||||||
questions={questions}
|
<View style={styles.questionHeader}>
|
||||||
activeIndex={resolvedActiveQuestionIndex}
|
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||||
isAnswered={navIsAnswered}
|
{activeQuestion?.question}
|
||||||
isResponding={isResponding}
|
</Text>
|
||||||
onSelect={handleSelectQuestion}
|
</View>
|
||||||
/>
|
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||||
<View style={styles.questionHeader}>
|
{questions.map((question, qIndex) => {
|
||||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||||
{activeQuestion?.question}
|
return (
|
||||||
</Text>
|
<QuestionNavButton
|
||||||
|
key={question.header}
|
||||||
|
index={qIndex}
|
||||||
|
total={questions.length}
|
||||||
|
isActive={isActive}
|
||||||
|
isResponding={isResponding}
|
||||||
|
onSelect={handleSelectQuestion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{activeQuestion ? (
|
{activeQuestion ? (
|
||||||
<View key={activeQuestion.question} style={styles.questionBlock}>
|
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||||
{activeQuestion.options.length > 0 ? (
|
{activeQuestion.options.length > 0 ? (
|
||||||
<View style={styles.optionsWrap} {...optionsGroupAccessibility}>
|
<View style={styles.optionsWrap}>
|
||||||
{activeQuestion.options.map((opt, optIndex) => (
|
{activeQuestion.options.map((opt, optIndex) => (
|
||||||
<QuestionOptionRow
|
<QuestionOptionRow
|
||||||
key={opt.label}
|
key={opt.label}
|
||||||
@@ -622,6 +546,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
questionBlock: {
|
questionBlock: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
questionTopRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
questionHeader: {
|
questionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -641,24 +571,24 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
questionNav: {
|
questionNav: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[3],
|
},
|
||||||
|
questionNavMobile: {
|
||||||
|
paddingRight: theme.spacing[1],
|
||||||
},
|
},
|
||||||
questionNavButton: {
|
questionNavButton: {
|
||||||
flexDirection: "row",
|
minWidth: 28,
|
||||||
|
height: 28,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
justifyContent: "center",
|
||||||
minHeight: 28,
|
borderRadius: 999,
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
},
|
},
|
||||||
questionNavText: {
|
questionNavText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
optionItem: {
|
optionItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -673,40 +603,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
optionItemContent: {
|
optionItemContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-start",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
optionTextBlock: {
|
optionTextBlock: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
gap: theme.spacing[1],
|
gap: 2,
|
||||||
},
|
},
|
||||||
optionLabel: {
|
optionLabel: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
},
|
||||||
optionDescription: {
|
optionDescription: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.xs,
|
||||||
lineHeight: 20,
|
lineHeight: 16,
|
||||||
},
|
},
|
||||||
selectionControl: {
|
optionCheckSlot: {
|
||||||
width: 18,
|
width: 16,
|
||||||
height: 18,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
borderWidth: theme.borderWidth[1],
|
marginLeft: "auto",
|
||||||
marginTop: 2, // optical-align 18px control to the 22px label first line
|
|
||||||
},
|
|
||||||
selectionControlCheckbox: {
|
|
||||||
borderRadius: theme.borderRadius.base,
|
|
||||||
},
|
|
||||||
selectionControlRadio: {
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
selectionRadioDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
},
|
||||||
otherInput: {
|
otherInput: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
|
|
||||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
|
||||||
import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { formatCaughtValue } from "./root-error-details";
|
|
||||||
|
|
||||||
interface RootErrorBoundaryProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RootErrorBoundaryState {
|
|
||||||
error: string | null;
|
|
||||||
resetKey: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErrorBoundaryState> {
|
|
||||||
state: RootErrorBoundaryState = {
|
|
||||||
error: null,
|
|
||||||
resetKey: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: unknown): Partial<RootErrorBoundaryState> {
|
|
||||||
return { error: formatCaughtValue(error) };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
|
|
||||||
console.error("[RootErrorBoundary] Unhandled render error", {
|
|
||||||
error: formatCaughtValue(error),
|
|
||||||
componentStack: errorInfo.componentStack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
retry = () => {
|
|
||||||
this.setState(({ resetKey }) => ({
|
|
||||||
error: null,
|
|
||||||
resetKey: resetKey + 1,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { error, resetKey } = this.state;
|
|
||||||
if (error !== null) {
|
|
||||||
return <RootErrorFallback error={error} onRetry={this.retry} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Fragment key={resetKey}>{this.props.children}</Fragment>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RootErrorFallbackProps {
|
|
||||||
error: string;
|
|
||||||
onRetry: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.container}
|
|
||||||
contentContainerStyle={styles.contentContainer}
|
|
||||||
testID="root-error-boundary"
|
|
||||||
>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
|
|
||||||
<Text style={styles.title}>{t("rootError.title")}</Text>
|
|
||||||
<Text style={styles.body}>{t("rootError.body")}</Text>
|
|
||||||
<View style={styles.messageBox}>
|
|
||||||
<Text style={styles.messageLabel}>{t("rootError.details")}</Text>
|
|
||||||
<Text style={styles.message}>{error}</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={onRetry}
|
|
||||||
style={retryButtonStyle}
|
|
||||||
testID="root-error-boundary-retry"
|
|
||||||
>
|
|
||||||
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
|
|
||||||
return [styles.retryButton, pressed ? styles.retryButtonPressed : null];
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingHorizontal: theme.spacing[6],
|
|
||||||
paddingVertical: theme.spacing[8],
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
alignSelf: "center",
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 520,
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
},
|
|
||||||
kicker: {
|
|
||||||
color: theme.colors.destructive,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.xl,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
|
||||||
messageBox: {
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
padding: theme.spacing[4],
|
|
||||||
},
|
|
||||||
messageLabel: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
retryButton: {
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
minHeight: 40,
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.accent,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
},
|
|
||||||
retryButtonPressed: {
|
|
||||||
opacity: 0.85,
|
|
||||||
},
|
|
||||||
retryButtonText: {
|
|
||||||
color: theme.colors.accentForeground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { formatCaughtValue } from "./root-error-details";
|
|
||||||
|
|
||||||
describe("formatCaughtValue", () => {
|
|
||||||
it("preserves details for Error values", () => {
|
|
||||||
class RouteRenderError extends Error {
|
|
||||||
code = "E_ROUTE_RENDER";
|
|
||||||
cause = "workspace route";
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super("route render exploded");
|
|
||||||
this.name = "RouteRenderError";
|
|
||||||
this.stack = "RouteRenderError: route render exploded\n at WorkspaceRoute";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = formatCaughtValue(new RouteRenderError());
|
|
||||||
|
|
||||||
expect(details).toContain("Name: RouteRenderError");
|
|
||||||
expect(details).toContain("Message: route render exploded");
|
|
||||||
expect(details).toContain("Stack:");
|
|
||||||
expect(details).toContain("RouteRenderError: route render exploded");
|
|
||||||
expect(details).toContain("Cause:");
|
|
||||||
expect(details).toContain("workspace route");
|
|
||||||
expect(details).toContain("E_ROUTE_RENDER");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not duplicate aggregate errors as custom fields", () => {
|
|
||||||
const error = new AggregateError([new Error("first failure")], "multiple failures");
|
|
||||||
const details = formatCaughtValue(error);
|
|
||||||
|
|
||||||
expect(details).toContain("Errors:");
|
|
||||||
expect(details).toContain("first failure");
|
|
||||||
expect(details).not.toContain("Fields:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves null aggregate error values", () => {
|
|
||||||
class ErrorWithNullableErrors extends Error {
|
|
||||||
errors = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = formatCaughtValue(new ErrorWithNullableErrors("nullable errors"));
|
|
||||||
|
|
||||||
expect(details).toContain("Errors:\nnull");
|
|
||||||
expect(details).not.toContain("Fields:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not throw for malformed Error text fields", () => {
|
|
||||||
const error = new Error("fallback");
|
|
||||||
Object.defineProperties(error, {
|
|
||||||
name: { configurable: true, value: null },
|
|
||||||
message: { configurable: true, value: 42 },
|
|
||||||
stack: { configurable: true, value: { frame: "bad stack" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const details = formatCaughtValue(error);
|
|
||||||
|
|
||||||
expect(details).toContain("Name: null");
|
|
||||||
expect(details).toContain("Message: 42");
|
|
||||||
expect(details).toContain('"frame": "bad stack"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks recursive Error causes", () => {
|
|
||||||
const error = new Error("self cause");
|
|
||||||
Object.defineProperty(error, "cause", { configurable: true, value: error });
|
|
||||||
|
|
||||||
const details = formatCaughtValue(error);
|
|
||||||
|
|
||||||
expect(details).toContain("Cause:\n[Circular Error]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns fallback details when Error properties throw", () => {
|
|
||||||
const error = new Error("fallback");
|
|
||||||
Object.defineProperty(error, "message", {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
throw new Error("bad message getter");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const details = formatCaughtValue(error);
|
|
||||||
|
|
||||||
expect(details).toContain("[Unserializable value]");
|
|
||||||
expect(details).toContain("Details unavailable:");
|
|
||||||
expect(details).toContain("Error: bad message getter");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders string thrown values as the string", () => {
|
|
||||||
expect(formatCaughtValue("plain failure")).toBe("plain failure");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves empty string thrown values", () => {
|
|
||||||
expect(formatCaughtValue("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders numeric thrown values without extra category text", () => {
|
|
||||||
const details = formatCaughtValue(42);
|
|
||||||
|
|
||||||
expect(details).toBe("42");
|
|
||||||
expect(details).not.toContain("non-Error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders circular objects as JSON with circular markers", () => {
|
|
||||||
const value: { label: string; self?: unknown } = { label: "loop" };
|
|
||||||
value.self = value;
|
|
||||||
|
|
||||||
expect(formatCaughtValue(value)).toBe('{\n "label": "loop",\n "self": "[Circular]"\n}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
export function formatCaughtValue(value: unknown): string {
|
|
||||||
try {
|
|
||||||
return formatCaughtValueWithSeenErrors(value, new WeakSet<Error>());
|
|
||||||
} catch (formattingError) {
|
|
||||||
return formatFormattingFailure(value, formattingError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet<Error>): string {
|
|
||||||
if (value instanceof Error) {
|
|
||||||
return formatError(value, seenErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return safeString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== "object" && typeof value !== "function") {
|
|
||||||
return safeString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stringifyJson(value, seenErrors) ?? safeString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatError(error: Error, seenErrors: WeakSet<Error>): string {
|
|
||||||
if (seenErrors.has(error)) {
|
|
||||||
return "[Circular Error]";
|
|
||||||
}
|
|
||||||
|
|
||||||
seenErrors.add(error);
|
|
||||||
const sections: string[] = [];
|
|
||||||
const name = formatErrorTextProperty(Reflect.get(error, "name"), seenErrors);
|
|
||||||
const message = formatErrorTextProperty(Reflect.get(error, "message"), seenErrors);
|
|
||||||
const stack = formatErrorTextProperty(Reflect.get(error, "stack"), seenErrors);
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
sections.push(`Name: ${name}`);
|
|
||||||
}
|
|
||||||
if (message) {
|
|
||||||
sections.push(`Message: ${message}`);
|
|
||||||
}
|
|
||||||
if (stack) {
|
|
||||||
sections.push(`Stack:\n${stack}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorCause = getErrorCause(error);
|
|
||||||
if (errorCause.hasCause) {
|
|
||||||
sections.push(`Cause:\n${formatCaughtValueWithSeenErrors(errorCause.value, seenErrors)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregateErrors = getAggregateErrors(error);
|
|
||||||
if (aggregateErrors.hasErrors) {
|
|
||||||
sections.push(`Errors:\n${formatCaughtValueWithSeenErrors(aggregateErrors.value, seenErrors)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = getErrorFields(error);
|
|
||||||
if (fields !== null) {
|
|
||||||
sections.push(`Fields:\n${stringifyJson(fields, seenErrors) ?? safeString(fields)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
seenErrors.delete(error);
|
|
||||||
return sections.join("\n\n") || safeString(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatErrorTextProperty(value: unknown, seenErrors: WeakSet<Error>): string | null {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed || null;
|
|
||||||
}
|
|
||||||
if (value === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return stringifyJson(value, seenErrors) ?? safeString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorCause(error: Error): { hasCause: boolean; value: unknown } {
|
|
||||||
if (!Reflect.has(error, "cause")) {
|
|
||||||
return { hasCause: false, value: null };
|
|
||||||
}
|
|
||||||
return { hasCause: true, value: Reflect.get(error, "cause") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAggregateErrors(error: Error): { hasErrors: boolean; value: unknown } {
|
|
||||||
if (!Reflect.has(error, "errors")) {
|
|
||||||
return { hasErrors: false, value: null };
|
|
||||||
}
|
|
||||||
return { hasErrors: true, value: Reflect.get(error, "errors") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorFields(error: Error): Record<string, unknown> | null {
|
|
||||||
const fields: Record<string, unknown> = {};
|
|
||||||
for (const key of Object.keys(error)) {
|
|
||||||
if (
|
|
||||||
key === "name" ||
|
|
||||||
key === "message" ||
|
|
||||||
key === "stack" ||
|
|
||||||
key === "cause" ||
|
|
||||||
key === "errors"
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
fields[key] = Reflect.get(error, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(fields).length > 0 ? fields : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyJson(value: unknown, seenErrors: WeakSet<Error>): string | null {
|
|
||||||
const seen = new WeakSet<object>();
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(
|
|
||||||
value,
|
|
||||||
(_key, nestedValue: unknown) => {
|
|
||||||
if (nestedValue instanceof Error) {
|
|
||||||
return formatError(nestedValue, seenErrors);
|
|
||||||
}
|
|
||||||
if (typeof nestedValue === "bigint") {
|
|
||||||
return String(nestedValue);
|
|
||||||
}
|
|
||||||
if (nestedValue !== null && typeof nestedValue === "object") {
|
|
||||||
if (seen.has(nestedValue)) {
|
|
||||||
return "[Circular]";
|
|
||||||
}
|
|
||||||
seen.add(nestedValue);
|
|
||||||
}
|
|
||||||
return nestedValue;
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
return typeof serialized === "string" ? serialized : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeString(value: unknown): string {
|
|
||||||
try {
|
|
||||||
return String(value);
|
|
||||||
} catch {
|
|
||||||
return "[Unserializable value]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFormattingFailure(value: unknown, formattingError: unknown): string {
|
|
||||||
const valueText = safeString(value);
|
|
||||||
const formattingErrorText = safeString(formattingError);
|
|
||||||
if (formattingErrorText === "[Unserializable value]") {
|
|
||||||
return valueText;
|
|
||||||
}
|
|
||||||
return `${valueText}\n\nDetails unavailable:\n${formattingErrorText}`;
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
|
||||||
import type { FieldControlSize } from "@/components/ui/control-geometry";
|
|
||||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
|
||||||
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
|
|
||||||
import {
|
|
||||||
CADENCE_PRESET_OPTIONS,
|
|
||||||
normalizeScheduleFormCadence,
|
|
||||||
resolveCronPresetDisplay,
|
|
||||||
resolveCronPresetId,
|
|
||||||
} from "@/schedules/schedule-cadence-options";
|
|
||||||
import { getDeviceTimeZone } from "@/utils/device-timezone";
|
|
||||||
import { describeCron, validateCron } from "@/utils/schedule-format";
|
|
||||||
|
|
||||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
|
||||||
|
|
||||||
export interface CadenceEditorProps {
|
|
||||||
value: ScheduleCadence;
|
|
||||||
onChange: (next: ScheduleCadence) => void;
|
|
||||||
error?: string;
|
|
||||||
size?: FieldControlSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
|
|
||||||
id: option.id,
|
|
||||||
value: option.id,
|
|
||||||
label: option.label,
|
|
||||||
testID: `schedule-cadence-preset-${option.id}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
|
|
||||||
if (error || !expression) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return describeCron({ type: "cron", expression, timezone }) ?? expression;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCronCadence(expression: string, timezone: string): CronCadence {
|
|
||||||
return { type: "cron", expression, timezone };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
|
|
||||||
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
|
|
||||||
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
|
|
||||||
const [cronText, setCronText] = useState(() => normalizedValue.expression);
|
|
||||||
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
|
|
||||||
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
|
|
||||||
const trimmedCron = cronText.trim();
|
|
||||||
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
|
|
||||||
const effectiveError = error ?? localCronError;
|
|
||||||
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
|
|
||||||
const currentCadence = useMemo<CronCadence>(
|
|
||||||
() => buildCronCadence(trimmedCron, cronTimeZone),
|
|
||||||
[cronTimeZone, trimmedCron],
|
|
||||||
);
|
|
||||||
const selectedPresetId = resolveCronPresetId(currentCadence);
|
|
||||||
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
|
|
||||||
|
|
||||||
const handlePresetChange = useCallback(
|
|
||||||
(presetId: string) => {
|
|
||||||
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
|
|
||||||
if (!preset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCronText(preset.expression);
|
|
||||||
bumpFieldResetKey();
|
|
||||||
onChange(buildCronCadence(preset.expression, cronTimeZone));
|
|
||||||
},
|
|
||||||
[cronTimeZone, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCronChange = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
setCronText(text);
|
|
||||||
onChange(buildCronCadence(text.trim(), cronTimeZone));
|
|
||||||
},
|
|
||||||
[cronTimeZone, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
let feedback: ReactNode = null;
|
|
||||||
if (effectiveError) {
|
|
||||||
feedback = <Text style={styles.error}>{effectiveError}</Text>;
|
|
||||||
} else if (preview) {
|
|
||||||
feedback = <Text style={styles.preview}>{preview}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field label="Cadence">
|
|
||||||
<View style={styles.stack}>
|
|
||||||
<SelectField
|
|
||||||
label="Cadence"
|
|
||||||
value={selectedPresetId === "custom" ? null : selectedPresetId}
|
|
||||||
selectedDisplay={selectedPresetDisplay}
|
|
||||||
options={PRESET_OPTIONS}
|
|
||||||
onChange={handlePresetChange}
|
|
||||||
placeholder="Select cadence"
|
|
||||||
emptyText="No cadences found"
|
|
||||||
searchable={false}
|
|
||||||
title="Cadence"
|
|
||||||
size={size}
|
|
||||||
triggerTestID="schedule-cadence-preset-trigger"
|
|
||||||
field={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextInput
|
|
||||||
size={size}
|
|
||||||
testID="cadence-cron-expression"
|
|
||||||
accessibilityLabel="Cron expression"
|
|
||||||
initialValue={cronText}
|
|
||||||
resetKey={`cadence-cron-${fieldResetKey}`}
|
|
||||||
value={cronText}
|
|
||||||
onChangeText={handleCronChange}
|
|
||||||
placeholder="0 9 * * *"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
spellCheck={false}
|
|
||||||
style={styles.cronInput}
|
|
||||||
/>
|
|
||||||
{feedback}
|
|
||||||
</View>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
stack: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
cronInput: {
|
|
||||||
fontFamily: theme.fontFamily.mono,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
color: theme.colors.palette.red[300],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,374 +0,0 @@
|
|||||||
import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native";
|
|
||||||
import { useCallback, useState, type ReactElement } from "react";
|
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { settingsStyles } from "@/styles/settings";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
|
||||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
|
|
||||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
|
||||||
// change (never call useUnistyles in render). See docs/unistyles.md.
|
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
|
||||||
const ThemedPause = withUnistyles(Pause);
|
|
||||||
const ThemedPlay = withUnistyles(Play);
|
|
||||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
||||||
const ThemedTrash2 = withUnistyles(Trash2);
|
|
||||||
const ThemedKebab = withUnistyles(MoreVertical);
|
|
||||||
|
|
||||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
|
||||||
|
|
||||||
const MENU_ICON_SIZE = 14;
|
|
||||||
const PROVIDER_ICON_SIZE = 16;
|
|
||||||
|
|
||||||
// Pending flags for each action so the parent table can wire a mutation hook
|
|
||||||
// and the row reflects in-flight state without owning the mutation itself.
|
|
||||||
export interface ScheduleRowPending {
|
|
||||||
pause?: boolean;
|
|
||||||
resume?: boolean;
|
|
||||||
runNow?: boolean;
|
|
||||||
delete?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleRowActions {
|
|
||||||
onEdit: () => void;
|
|
||||||
onPause: () => void;
|
|
||||||
onResume: () => void;
|
|
||||||
onRunNow: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleRowProps extends ScheduleRowActions {
|
|
||||||
schedule: ScheduleSummary;
|
|
||||||
/** Client-derived target line (agent title / project / shortened path). */
|
|
||||||
targetLabel: string;
|
|
||||||
/** Provider glyph, resolved from the schedule config or the target agent. */
|
|
||||||
provider: string | null;
|
|
||||||
/** Client-derived state — the single source for the badge and next-run copy. */
|
|
||||||
state: ScheduleDerivedState;
|
|
||||||
/** Host name, rendered when the list spans more than one host. */
|
|
||||||
serverName?: string;
|
|
||||||
/** True when only one host exists and the host name would be redundant. */
|
|
||||||
singleHost?: boolean;
|
|
||||||
pending?: ScheduleRowPending;
|
|
||||||
isFirst: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateBadge(state: ScheduleDerivedState): {
|
|
||||||
label: string;
|
|
||||||
variant: "success" | "error" | "muted";
|
|
||||||
} {
|
|
||||||
switch (state) {
|
|
||||||
case "active":
|
|
||||||
return { label: "Active", variant: "success" };
|
|
||||||
case "paused":
|
|
||||||
return { label: "Paused", variant: "muted" };
|
|
||||||
case "expired":
|
|
||||||
return { label: "Expired", variant: "muted" };
|
|
||||||
case "finished":
|
|
||||||
return { label: "Finished", variant: "muted" };
|
|
||||||
case "targetGone":
|
|
||||||
return { label: "Target gone", variant: "error" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
|
||||||
// was created, when it last ran, and (only while it can still run) when it runs
|
|
||||||
// next. Status lives on the badge, never repeated here.
|
|
||||||
function buildMeta(
|
|
||||||
schedule: ScheduleSummary,
|
|
||||||
state: ScheduleDerivedState,
|
|
||||||
serverName: string | undefined,
|
|
||||||
singleHost: boolean,
|
|
||||||
): string {
|
|
||||||
const parts = [
|
|
||||||
formatCadence(schedule.cadence),
|
|
||||||
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
|
||||||
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
|
||||||
];
|
|
||||||
if (state === "active") {
|
|
||||||
const next = formatNextRun(schedule.nextRunAt);
|
|
||||||
if (next) {
|
|
||||||
parts.push(`Next run ${next}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (serverName && !singleHost) {
|
|
||||||
parts.unshift(serverName);
|
|
||||||
}
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
|
||||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
|
||||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
|
||||||
if (!provider) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const Icon = getProviderIcon(provider);
|
|
||||||
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One schedule, rendered as a settings-style card row: provider glyph + title,
|
|
||||||
* a muted secondary line (model · cadence · next run), a StatusBadge, and the
|
|
||||||
* kebab menu that owns every row action. Tapping the row opens the editor.
|
|
||||||
*
|
|
||||||
* Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns
|
|
||||||
* press, the nested kebab Pressable never fights it, and the row background
|
|
||||||
* highlights without reflow.
|
|
||||||
*/
|
|
||||||
export function ScheduleRow({
|
|
||||||
schedule,
|
|
||||||
targetLabel,
|
|
||||||
provider,
|
|
||||||
state,
|
|
||||||
serverName,
|
|
||||||
singleHost,
|
|
||||||
pending,
|
|
||||||
isFirst,
|
|
||||||
onEdit,
|
|
||||||
onPause,
|
|
||||||
onResume,
|
|
||||||
onRunNow,
|
|
||||||
onDelete,
|
|
||||||
}: ScheduleRowProps): ReactElement {
|
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
||||||
|
|
||||||
const title = resolveScheduleTitle(schedule);
|
|
||||||
const badge = stateBadge(state);
|
|
||||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
|
||||||
const canRun = state === "active" || state === "paused";
|
|
||||||
|
|
||||||
const rowStyle = useCallback(
|
|
||||||
({ pressed }: PressableStateCallbackType) => [
|
|
||||||
settingsStyles.row,
|
|
||||||
styles.row,
|
|
||||||
!isFirst && settingsStyles.rowBorder,
|
|
||||||
isHovered && !isCompact && styles.rowHovered,
|
|
||||||
pressed && styles.rowPressed,
|
|
||||||
],
|
|
||||||
[isFirst, isHovered, isCompact],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={styles.rowContainer}
|
|
||||||
onPointerEnter={handlePointerEnter}
|
|
||||||
onPointerLeave={handlePointerLeave}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={rowStyle}
|
|
||||||
onPress={onEdit}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Edit schedule ${title}`}
|
|
||||||
testID={`schedule-row-${schedule.id}`}
|
|
||||||
>
|
|
||||||
<View style={styles.main}>
|
|
||||||
<View style={styles.leading}>
|
|
||||||
<ProviderGlyph provider={provider} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.textGroup}>
|
|
||||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.target} numberOfLines={1}>
|
|
||||||
{targetLabel}
|
|
||||||
</Text>
|
|
||||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
|
||||||
{meta}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.trailing}>
|
|
||||||
<StatusBadge label={badge.label} variant={badge.variant} />
|
|
||||||
<ScheduleKebabMenu
|
|
||||||
schedule={schedule}
|
|
||||||
canRun={canRun}
|
|
||||||
pending={pending}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onPause={onPause}
|
|
||||||
onResume={onResume}
|
|
||||||
onRunNow={onRunNow}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const editLeading = <ThemedPencil size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
|
||||||
const pauseLeading = <ThemedPause size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
|
||||||
const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
|
||||||
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
|
||||||
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
|
|
||||||
|
|
||||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
|
|
||||||
return (
|
|
||||||
<ThemedKebab
|
|
||||||
size={MENU_ICON_SIZE}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScheduleKebabMenu({
|
|
||||||
schedule,
|
|
||||||
canRun,
|
|
||||||
pending,
|
|
||||||
onEdit,
|
|
||||||
onPause,
|
|
||||||
onResume,
|
|
||||||
onRunNow,
|
|
||||||
onDelete,
|
|
||||||
}: Pick<
|
|
||||||
ScheduleRowProps,
|
|
||||||
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
|
|
||||||
> & {
|
|
||||||
canRun: boolean;
|
|
||||||
}): ReactElement {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
hitSlop={8}
|
|
||||||
style={kebabTriggerStyle}
|
|
||||||
accessibilityRole={isNative ? "button" : undefined}
|
|
||||||
accessibilityLabel="Schedule actions"
|
|
||||||
testID={`schedule-kebab-${schedule.id}`}
|
|
||||||
>
|
|
||||||
{renderKebabTriggerIcon}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={220}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
leading={editLeading}
|
|
||||||
onSelect={onEdit}
|
|
||||||
testID={`schedule-menu-edit-${schedule.id}`}
|
|
||||||
>
|
|
||||||
Edit schedule
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{schedule.status === "paused" ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
leading={resumeLeading}
|
|
||||||
disabled={!canRun}
|
|
||||||
status={pending?.resume ? "pending" : "idle"}
|
|
||||||
pendingLabel="Resuming..."
|
|
||||||
onSelect={onResume}
|
|
||||||
testID={`schedule-menu-resume-${schedule.id}`}
|
|
||||||
>
|
|
||||||
Resume schedule
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
|
||||||
leading={pauseLeading}
|
|
||||||
disabled={schedule.status === "completed" || !canRun}
|
|
||||||
status={pending?.pause ? "pending" : "idle"}
|
|
||||||
pendingLabel="Pausing..."
|
|
||||||
onSelect={onPause}
|
|
||||||
testID={`schedule-menu-pause-${schedule.id}`}
|
|
||||||
>
|
|
||||||
Pause schedule
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
leading={runLeading}
|
|
||||||
disabled={!canRun}
|
|
||||||
status={pending?.runNow ? "pending" : "idle"}
|
|
||||||
pendingLabel="Starting..."
|
|
||||||
onSelect={onRunNow}
|
|
||||||
testID={`schedule-menu-run-${schedule.id}`}
|
|
||||||
>
|
|
||||||
Run now
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
leading={deleteLeading}
|
|
||||||
destructive
|
|
||||||
status={pending?.delete ? "pending" : "idle"}
|
|
||||||
pendingLabel="Deleting..."
|
|
||||||
onSelect={onDelete}
|
|
||||||
testID={`schedule-menu-delete-${schedule.id}`}
|
|
||||||
>
|
|
||||||
Delete schedule
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function kebabTriggerStyle({
|
|
||||||
hovered = false,
|
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
||||||
return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
// Static color holder for the dynamic provider icon (compliant idiom).
|
|
||||||
providerIcon: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
rowContainer: {
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
rowHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
rowPressed: {
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
},
|
|
||||||
main: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
leading: {
|
|
||||||
width: PROVIDER_ICON_SIZE,
|
|
||||||
height: PROVIDER_ICON_SIZE,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
textGroup: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
trailing: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
kebabTrigger: {
|
|
||||||
padding: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.base,
|
|
||||||
},
|
|
||||||
kebabTriggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { useCallback, useState, type ReactElement } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
|
||||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
|
||||||
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
|
||||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
|
||||||
import { settingsStyles } from "@/styles/settings";
|
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
||||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
|
||||||
|
|
||||||
/** A schedule plus the client-derived fields the row renders. */
|
|
||||||
export interface ScheduleRowView {
|
|
||||||
schedule: AggregatedSchedule;
|
|
||||||
targetLabel: string;
|
|
||||||
provider: string | null;
|
|
||||||
state: ScheduleDerivedState;
|
|
||||||
serverName: string;
|
|
||||||
/** True when only one host exists, so the host name is redundant in rows. */
|
|
||||||
singleHost: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchedulesTableProps {
|
|
||||||
rows: ScheduleRowView[];
|
|
||||||
/**
|
|
||||||
* The form sheet is owned by the screen (it serves both create and edit and
|
|
||||||
* shares the screen's "New schedule" button), so the table delegates edit
|
|
||||||
* upward rather than mounting a second sheet here.
|
|
||||||
*/
|
|
||||||
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The schedules list: a single settings-style card of rows across every
|
|
||||||
* connected host, in a full-width list matching the History screen. Rows own
|
|
||||||
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
|
|
||||||
* a destructive confirm) and delegate editing upward.
|
|
||||||
*/
|
|
||||||
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<View style={styles.listContent} testID="schedules-table">
|
|
||||||
<View style={settingsStyles.card}>
|
|
||||||
{rows.map((row, index) => (
|
|
||||||
<SchedulesTableRow
|
|
||||||
key={`${row.schedule.serverId}:${row.schedule.id}`}
|
|
||||||
row={row}
|
|
||||||
isFirst={index === 0}
|
|
||||||
onEditSchedule={onEditSchedule}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-row wrapper owns local in-flight state and binds mutations to this
|
|
||||||
// schedule's host. Local state keeps pending precise to the acting row even
|
|
||||||
// when several rows are acted on at once (the mutations hook exposes only a
|
|
||||||
// single global pending flag per action).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const NO_PENDING: ScheduleRowPending = {};
|
|
||||||
|
|
||||||
function SchedulesTableRow({
|
|
||||||
row,
|
|
||||||
isFirst,
|
|
||||||
onEditSchedule,
|
|
||||||
}: {
|
|
||||||
row: ScheduleRowView;
|
|
||||||
isFirst: boolean;
|
|
||||||
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
|
||||||
}): ReactElement {
|
|
||||||
const { schedule } = row;
|
|
||||||
const { id, serverId } = schedule;
|
|
||||||
const mutations = useScheduleMutations({ serverId });
|
|
||||||
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
|
||||||
|
|
||||||
const runAction = useCallback(
|
|
||||||
async (key: keyof ScheduleRowPending, action: () => Promise<void>): Promise<void> => {
|
|
||||||
setPending((current) => ({ ...current, [key]: true }));
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} catch {
|
|
||||||
// Mutations roll back their own optimistic cache writes on error and
|
|
||||||
// re-fetch on settle; surfacing per-row toasts here is out of scope.
|
|
||||||
} finally {
|
|
||||||
setPending((current) => {
|
|
||||||
const next = { ...current };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
|
||||||
onEditSchedule(schedule);
|
|
||||||
}, [onEditSchedule, schedule]);
|
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
|
||||||
void runAction("pause", () => mutations.pauseSchedule(id));
|
|
||||||
}, [runAction, mutations, id]);
|
|
||||||
|
|
||||||
const handleResume = useCallback(() => {
|
|
||||||
void runAction("resume", () => mutations.resumeSchedule(id));
|
|
||||||
}, [runAction, mutations, id]);
|
|
||||||
|
|
||||||
const handleRunNow = useCallback(() => {
|
|
||||||
void runAction("runNow", () => mutations.runScheduleNow(id));
|
|
||||||
}, [runAction, mutations, id]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
void (async () => {
|
|
||||||
const confirmed = await confirmDialog({
|
|
||||||
title: "Delete schedule",
|
|
||||||
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
destructive: true,
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runAction("delete", () => mutations.deleteSchedule(id));
|
|
||||||
})();
|
|
||||||
}, [runAction, mutations, id, schedule]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScheduleRow
|
|
||||||
schedule={schedule}
|
|
||||||
targetLabel={row.targetLabel}
|
|
||||||
provider={row.provider}
|
|
||||||
state={row.state}
|
|
||||||
serverName={row.serverName}
|
|
||||||
singleHost={row.singleHost}
|
|
||||||
isFirst={isFirst}
|
|
||||||
pending={pending}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onPause={handlePause}
|
|
||||||
onResume={handleResume}
|
|
||||||
onRunNow={handleRunNow}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
// Full-width list padding matching the History screen.
|
|
||||||
listContent: {
|
|
||||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -38,13 +38,17 @@ import { type GestureType } from "react-native-gesture-handler";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
|
Archive,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
|
CircleCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Settings,
|
Settings,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
@@ -52,7 +56,6 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
|||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +67,7 @@ import {
|
|||||||
shouldShowSidebarHostLabels,
|
shouldShowSidebarHostLabels,
|
||||||
useSidebarWorkspaceEntry,
|
useSidebarWorkspaceEntry,
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
|
type SidebarStatusWorkspacePlacement,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
type SidebarWorkspacePlacement,
|
type SidebarWorkspacePlacement,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
@@ -88,8 +92,6 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
|||||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
@@ -139,11 +141,15 @@ const ThemedExternalLink = withUnistyles(ExternalLink);
|
|||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||||
const ThemedPlus = withUnistyles(Plus);
|
const ThemedPlus = withUnistyles(Plus);
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
const ThemedTrash2 = withUnistyles(Trash2);
|
const ThemedTrash2 = withUnistyles(Trash2);
|
||||||
const ThemedSettings = withUnistyles(Settings);
|
const ThemedSettings = withUnistyles(Settings);
|
||||||
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({
|
const foregroundColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -221,7 +227,7 @@ function selectionForSelectedWorkspace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarWorkspaceListProps {
|
interface SidebarWorkspaceListProps {
|
||||||
statusGroups: StatusGroup[];
|
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
@@ -351,6 +357,12 @@ function projectKebabStyle({
|
|||||||
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspaceKebabStyle({
|
||||||
|
hovered = false,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
function getProjectWorkspaceRowStyle({
|
function getProjectWorkspaceRowStyle({
|
||||||
isDragging,
|
isDragging,
|
||||||
selected,
|
selected,
|
||||||
@@ -527,6 +539,12 @@ function ProjectRowTrailingActions({
|
|||||||
|
|
||||||
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const markAsReadLeadingIcon = (
|
||||||
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const openInNewWindowLeadingIcon = (
|
const openInNewWindowLeadingIcon = (
|
||||||
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
);
|
);
|
||||||
@@ -674,7 +692,7 @@ function WorkspaceRowRightGroup({
|
|||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<SidebarWorkspaceMenu
|
<WorkspaceKebabMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
@@ -694,6 +712,97 @@ function WorkspaceRowRightGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceKebabMenu({
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
}: {
|
||||||
|
workspaceKey: string;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
onArchive: () => void;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const archiveTrailing = useMemo(
|
||||||
|
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||||
|
[archiveShortcutKeys],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={workspaceKebabStyle}
|
||||||
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||||
|
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||||
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||||
|
>
|
||||||
|
{renderKebabTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260}>
|
||||||
|
{onCopyPath ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyPath}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyPath")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyBranchName")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.rename")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
trailing={archiveTrailing}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectIcon({
|
function ProjectIcon({
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
placeholderInitial,
|
placeholderInitial,
|
||||||
@@ -1640,13 +1749,14 @@ function WorkspaceRowItem({
|
|||||||
isDragging = false,
|
isDragging = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: WorkspaceRowItemProps) {
|
}: WorkspaceRowItemProps) {
|
||||||
|
const currentPathname = usePathname();
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (!workspace.serverId) {
|
if (!workspace.serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
@@ -1772,7 +1882,6 @@ function ProjectBlock({
|
|||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
supportsMultiplicityByServerId,
|
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -1794,16 +1903,14 @@ function ProjectBlock({
|
|||||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
showHostLabels: boolean;
|
showHostLabels: boolean;
|
||||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
|
||||||
}) {
|
}) {
|
||||||
const rowModel = useMemo(
|
const rowModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSidebarProjectRowModel({
|
buildSidebarProjectRowModel({
|
||||||
project,
|
project,
|
||||||
collapsed,
|
collapsed,
|
||||||
supportsMultiplicityByServerId,
|
|
||||||
}),
|
}),
|
||||||
[collapsed, project, supportsMultiplicityByServerId],
|
[collapsed, project],
|
||||||
);
|
);
|
||||||
|
|
||||||
const active = isProjectSelectedByRoute({
|
const active = isProjectSelectedByRoute({
|
||||||
@@ -1956,7 +2063,7 @@ function ProjectBlock({
|
|||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
} else if (rowModel.trailingAction.kind === "new_worktree") {
|
||||||
projectChildren = (
|
projectChildren = (
|
||||||
<NewWorkspaceGhostRow
|
<NewWorkspaceGhostRow
|
||||||
project={project}
|
project={project}
|
||||||
@@ -1979,7 +2086,7 @@ function ProjectBlock({
|
|||||||
chevron={rowModel.chevron}
|
chevron={rowModel.chevron}
|
||||||
onPress={handleToggleCollapsed}
|
onPress={handleToggleCollapsed}
|
||||||
worktreeTarget={
|
worktreeTarget={
|
||||||
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
|
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
|
||||||
}
|
}
|
||||||
isProjectActive={active}
|
isProjectActive={active}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -2000,7 +2107,6 @@ function ProjectBlock({
|
|||||||
|
|
||||||
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||||
|
|
||||||
// oxlint-disable-next-line complexity
|
|
||||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||||
return (
|
return (
|
||||||
previous.project === next.project &&
|
previous.project === next.project &&
|
||||||
@@ -2012,7 +2118,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||||
previous.showHostLabels === next.showHostLabels &&
|
previous.showHostLabels === next.showHostLabels &&
|
||||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
|
||||||
previous.parentGestureRef === next.parentGestureRef &&
|
previous.parentGestureRef === next.parentGestureRef &&
|
||||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||||
@@ -2056,7 +2161,7 @@ function areProjectBlockSelectionsEqual(
|
|||||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||||
|
|
||||||
export function SidebarWorkspaceList({
|
export function SidebarWorkspaceList({
|
||||||
statusGroups,
|
statusWorkspacePlacements,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
@@ -2079,14 +2184,12 @@ export function SidebarWorkspaceList({
|
|||||||
}
|
}
|
||||||
return labels;
|
return labels;
|
||||||
}, [hosts]);
|
}, [hosts]);
|
||||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
|
||||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
|
||||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||||
|
|
||||||
const content =
|
const content =
|
||||||
groupMode === "status" ? (
|
groupMode === "status" ? (
|
||||||
<SidebarStatusModeWrapper
|
<SidebarStatusModeWrapper
|
||||||
statusGroups={statusGroups}
|
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -2106,7 +2209,6 @@ export function SidebarWorkspaceList({
|
|||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostLabelByServerId={hostLabelByServerId}
|
||||||
showHostLabels={showHostLabels}
|
showHostLabels={showHostLabels}
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2114,14 +2216,14 @@ export function SidebarWorkspaceList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarStatusModeWrapper({
|
function SidebarStatusModeWrapper({
|
||||||
statusGroups,
|
statusWorkspacePlacements,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
}: {
|
}: {
|
||||||
statusGroups: StatusGroup[];
|
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
@@ -2132,7 +2234,7 @@ function SidebarStatusModeWrapper({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarStatusWorkspaceList
|
<SidebarStatusWorkspaceList
|
||||||
groups={statusGroups}
|
workspaces={statusWorkspacePlacements}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
@@ -2155,15 +2257,13 @@ function ProjectModeList({
|
|||||||
pathname,
|
pathname,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
supportsMultiplicityByServerId,
|
|
||||||
}: Omit<
|
}: Omit<
|
||||||
SidebarWorkspaceListProps,
|
SidebarWorkspaceListProps,
|
||||||
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||||
> & {
|
> & {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
showHostLabels: boolean;
|
showHostLabels: boolean;
|
||||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
@@ -2352,7 +2452,6 @@ function ProjectModeList({
|
|||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostLabelByServerId={hostLabelByServerId}
|
||||||
showHostLabels={showHostLabels}
|
showHostLabels={showHostLabels}
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -2363,7 +2462,6 @@ function ProjectModeList({
|
|||||||
handleWorkspaceReorder,
|
handleWorkspaceReorder,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
supportsMultiplicityByServerId,
|
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
@@ -2762,6 +2860,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
kebabButton: {
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
kebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
statusDotNeedsInput: {
|
statusDotNeedsInput: {
|
||||||
backgroundColor: theme.colors.palette.amber[500],
|
backgroundColor: theme.colors.palette.amber[500],
|
||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
useSidebarWorkspacesList,
|
|
||||||
type SidebarWorkspacesListResult,
|
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
|
||||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
|
||||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
|
||||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
|
||||||
import {
|
|
||||||
buildSidebarShortcutModel,
|
|
||||||
buildStatusSidebarShortcutModel,
|
|
||||||
type SidebarShortcutModel,
|
|
||||||
} from "@/utils/sidebar-shortcuts";
|
|
||||||
|
|
||||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
|
||||||
groupMode: SidebarGroupMode;
|
|
||||||
statusGroups: StatusGroup[];
|
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
|
||||||
toggleProjectCollapsed: (projectKey: string) => void;
|
|
||||||
shortcutModel: SidebarShortcutModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
|
||||||
|
|
||||||
export function SidebarModelProvider({ children }: { children: ReactNode }) {
|
|
||||||
const list = useSidebarWorkspacesList();
|
|
||||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
|
||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
|
||||||
(state) => state.collapsedProjectKeys,
|
|
||||||
);
|
|
||||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
|
||||||
(state) => state.collapsedStatusGroupKeys,
|
|
||||||
);
|
|
||||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
|
||||||
(state) => state.toggleProjectCollapsed,
|
|
||||||
);
|
|
||||||
const isStatusMode = groupMode === "status";
|
|
||||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
|
||||||
placements: list.workspacePlacements,
|
|
||||||
enabled: isStatusMode,
|
|
||||||
});
|
|
||||||
const statusGroups = useMemo(
|
|
||||||
() =>
|
|
||||||
isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [],
|
|
||||||
[isStatusMode, list.projectNamesByKey, statusWorkspacePlacements],
|
|
||||||
);
|
|
||||||
const shortcutModel = useMemo(() => {
|
|
||||||
if (isStatusMode) {
|
|
||||||
return buildStatusSidebarShortcutModel({
|
|
||||||
groups: statusGroups,
|
|
||||||
collapsedStatusGroupKeys,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys });
|
|
||||||
}, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]);
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
...list,
|
|
||||||
groupMode,
|
|
||||||
statusGroups,
|
|
||||||
collapsedProjectKeys,
|
|
||||||
toggleProjectCollapsed,
|
|
||||||
shortcutModel,
|
|
||||||
}),
|
|
||||||
[collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSidebarModel(): SidebarModel {
|
|
||||||
const model = useContext(SidebarModelContext);
|
|
||||||
if (!model) throw new Error("SidebarModelProvider is required");
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useCallback, useMemo, useState } from "react";
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
@@ -9,7 +10,11 @@ import {
|
|||||||
type SidebarStatusWorkspacePlacement,
|
type SidebarStatusWorkspacePlacement,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
import {
|
||||||
|
buildStatusGroups,
|
||||||
|
buildStatusShortcutIndex,
|
||||||
|
type StatusGroup,
|
||||||
|
} from "@/hooks/sidebar-status-view-model";
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -21,8 +26,18 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
CircleX,
|
CircleX,
|
||||||
|
MoreVertical,
|
||||||
|
Copy,
|
||||||
|
Archive,
|
||||||
|
Pencil,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
@@ -33,6 +48,7 @@ import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
|||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
@@ -45,9 +61,9 @@ import {
|
|||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
|
||||||
|
|
||||||
// Themed icon wrappers
|
// Themed icon wrappers
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
@@ -62,8 +78,20 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
|||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
const ThemedCircleX = withUnistyles(CircleX);
|
const ThemedCircleX = withUnistyles(CircleX);
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
|
||||||
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const markAsReadLeadingIcon = (
|
||||||
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
interface StatusWorkspaceListProps {
|
interface StatusWorkspaceListProps {
|
||||||
groups: StatusGroup[];
|
workspaces: SidebarStatusWorkspacePlacement[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
@@ -73,19 +101,31 @@ interface StatusWorkspaceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
groups,
|
workspaces,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
|
const groups = useMemo(
|
||||||
|
() => buildStatusGroups(workspaces, projectNamesByKey),
|
||||||
|
[workspaces, projectNamesByKey],
|
||||||
|
);
|
||||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedStatusGroupKeys,
|
(state) => state.collapsedStatusGroupKeys,
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
const statusShortcutIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
showShortcutBadges
|
||||||
|
? buildStatusShortcutIndex(
|
||||||
|
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
|
||||||
|
)
|
||||||
|
: new Map<string, number>(),
|
||||||
|
[collapsedStatusGroupKeys, groups, showShortcutBadges],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -294,6 +334,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
}) {
|
}) {
|
||||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
|
const currentPathname = usePathname();
|
||||||
const selected =
|
const selected =
|
||||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||||
@@ -301,8 +342,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (!workspace.serverId) return;
|
if (!workspace.serverId) return;
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
if (!workspaceEntry) return null;
|
if (!workspaceEntry) return null;
|
||||||
|
|
||||||
@@ -618,7 +659,7 @@ function StatusWorkspaceActionSlot({
|
|||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<SidebarWorkspaceMenu
|
<StatusKebabMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
@@ -636,6 +677,105 @@ function StatusWorkspaceActionSlot({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusKebabMenu({
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
}: {
|
||||||
|
workspaceKey: string;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
onArchive: () => void;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
}) {
|
||||||
|
const archiveTrailing = useMemo(
|
||||||
|
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||||
|
[archiveShortcutKeys],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={kebabStyle}
|
||||||
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||||
|
accessibilityLabel="Workspace actions"
|
||||||
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||||
|
>
|
||||||
|
{({ hovered }: { hovered?: boolean }) => (
|
||||||
|
<ThemedMoreVertical
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260}>
|
||||||
|
{onCopyPath ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyPath}
|
||||||
|
>
|
||||||
|
Copy path
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
Copy branch name
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
Rename workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
trailing={archiveTrailing}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? "Archive"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusWorkspaceRowStyle({
|
function getStatusWorkspaceRowStyle({
|
||||||
selected,
|
selected,
|
||||||
isHovered,
|
isHovered,
|
||||||
@@ -738,4 +878,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarRowSelected: {
|
sidebarRowSelected: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
|
kebabButton: {
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
kebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { type PressableStateCallbackType } from "react-native";
|
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
||||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
|
||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
||||||
|
|
||||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const markAsReadLeadingIcon = (
|
|
||||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
|
|
||||||
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|
||||||
return (
|
|
||||||
<ThemedMoreVertical
|
|
||||||
size={14}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarWorkspaceMenuProps {
|
|
||||||
workspaceKey: string;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
onArchive: () => void;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarWorkspaceMenu({
|
|
||||||
workspaceKey,
|
|
||||||
onCopyPath,
|
|
||||||
onCopyBranchName,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
onArchive,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus,
|
|
||||||
archivePendingLabel,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: SidebarWorkspaceMenuProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const archiveTrailing = useMemo(
|
|
||||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
||||||
[archiveShortcutKeys],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
hitSlop={8}
|
|
||||||
style={triggerStyle}
|
|
||||||
accessibilityRole={isWeb ? undefined : "button"}
|
|
||||||
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
|
||||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
||||||
>
|
|
||||||
{renderTriggerIcon}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={260}>
|
|
||||||
{onCopyPath ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyPath}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.copyPath")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onCopyBranchName ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyBranchName}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.copyBranchName")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onRename ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
||||||
leading={renameLeadingIcon}
|
|
||||||
onSelect={onRename}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.rename")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onMarkAsRead ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
||||||
leading={markAsReadLeadingIcon}
|
|
||||||
onSelect={onMarkAsRead}
|
|
||||||
>
|
|
||||||
Mark as read
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
||||||
leading={archiveLeadingIcon}
|
|
||||||
trailing={archiveTrailing}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
|
||||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
||||||
return [styles.trigger, hovered && styles.triggerHovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
trigger: {
|
|
||||||
padding: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginLeft: 2,
|
|
||||||
},
|
|
||||||
triggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
@@ -19,9 +28,8 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
import { isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
@@ -30,6 +38,31 @@ import {
|
|||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
|
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
|
|
||||||
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const markAsReadLeadingIcon = (
|
||||||
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ThemedMoreVertical
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
|
|
||||||
interface SidebarWorkspaceRowProps {
|
interface SidebarWorkspaceRowProps {
|
||||||
@@ -425,7 +458,7 @@ function WorkspaceRowTrailingActions({
|
|||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<SidebarWorkspaceMenu
|
<WorkspaceKebabMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
@@ -445,6 +478,103 @@ function WorkspaceRowTrailingActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceKebabMenu({
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
}: {
|
||||||
|
workspaceKey: string;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
onArchive: () => void;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const archiveTrailing = useMemo(
|
||||||
|
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||||
|
[archiveShortcutKeys],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={workspaceKebabStyle}
|
||||||
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||||
|
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||||
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||||
|
>
|
||||||
|
{renderKebabTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260}>
|
||||||
|
{onCopyPath ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyPath}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyPath")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyBranchName")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.rename")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
trailing={archiveTrailing}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceKebabStyle({
|
||||||
|
hovered = false,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkspaceRowStyle({
|
function getWorkspaceRowStyle({
|
||||||
isDragging,
|
isDragging,
|
||||||
selected,
|
selected,
|
||||||
@@ -500,4 +630,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
kebabButton: {
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
kebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user