mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
android-ch
...
lenient-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eec33773a | ||
|
|
5616c6af6a |
@@ -1,31 +1,11 @@
|
||||
.git
|
||||
.debug.conversations
|
||||
.debug
|
||||
.dev
|
||||
.playwright-mcp
|
||||
**/.playwright-mcp
|
||||
.paseo
|
||||
**/.paseo-provider-history
|
||||
.plans
|
||||
.tasks
|
||||
.valknut
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees
|
||||
.wrangler
|
||||
**/.wrangler
|
||||
**/.tanstack
|
||||
PLAN.md
|
||||
valknut-report.html
|
||||
valknut-report.json
|
||||
.env*
|
||||
**/.env*
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
*.pem
|
||||
**/*.pem
|
||||
**/.secrets
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -76,6 +76,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -112,6 +113,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
@@ -168,6 +170,7 @@ jobs:
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -199,6 +202,7 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -223,6 +227,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
@@ -239,13 +244,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { label: "shard 1/4", shard: 1, desktop: false }
|
||||
- { 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 }})
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -270,6 +270,7 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -281,19 +282,13 @@ jobs:
|
||||
run: npm run build:server
|
||||
|
||||
- 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
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
1
.github/workflows/deploy-app.yml
vendored
1
.github/workflows/deploy-app.yml
vendored
@@ -27,6 +27,7 @@ jobs:
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
|
||||
7
.github/workflows/deploy-relay.yml
vendored
7
.github/workflows/deploy-relay.yml
vendored
@@ -1,8 +1,11 @@
|
||||
name: Deploy Relay
|
||||
|
||||
on:
|
||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
62
.github/workflows/docker.yml
vendored
62
.github/workflows/docker.yml
vendored
@@ -5,22 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".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:
|
||||
branches: [main]
|
||||
tags:
|
||||
@@ -28,7 +13,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
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
|
||||
default: ""
|
||||
publish:
|
||||
@@ -39,6 +24,15 @@ on:
|
||||
options:
|
||||
- "false"
|
||||
- "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:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
@@ -63,6 +57,7 @@ jobs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
source_build: ${{ steps.values.outputs.source_build }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
@@ -73,6 +68,7 @@ jobs:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -80,9 +76,9 @@ jobs:
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||
publish=false
|
||||
source_build=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
@@ -91,6 +87,7 @@ jobs:
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
source_build=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -107,6 +104,24 @@ jobs:
|
||||
prerelease=true
|
||||
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
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -122,13 +137,14 @@ jobs:
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "source_build=${source_build}"
|
||||
echo "check_tag=${check_tag}"
|
||||
echo "publish_tags<<EOF"
|
||||
echo "${publish_tags}"
|
||||
echo "EOF"
|
||||
} >> "$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:
|
||||
needs: setup
|
||||
@@ -144,8 +160,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
@@ -177,8 +193,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,7 +84,6 @@ valknut-report.json/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
packages/protocol/src/generated/validation/*.aot.ts
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
|
||||
192
.oxlintrc.json
192
.oxlintrc.json
@@ -84,198 +84,6 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"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"],
|
||||
"rules": {
|
||||
|
||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -1,84 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.106 - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||
- Switching between workspaces no longer flashes a white screen
|
||||
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 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
|
||||
|
||||
65
CLAUDE.md
65
CLAUDE.md
@@ -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/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [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/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/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/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/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/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/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/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/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [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 |
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [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/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/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/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [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/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/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/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/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/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [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
|
||||
|
||||
@@ -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`
|
||||
- **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.
|
||||
- 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).
|
||||
- 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?"
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
# 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; \
|
||||
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}
|
||||
|
||||
ARG PASEO_VERSION=latest
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
PASEO_LISTEN=0.0.0.0:6767 \
|
||||
@@ -52,14 +29,13 @@ RUN set -eux; \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm install -g --omit=optional \
|
||||
"@getpaseo/server@${PASEO_VERSION}" \
|
||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
@@ -90,7 +66,7 @@ RUN set -eux; \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY docker/base/rootfs/ /
|
||||
COPY rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
101
docker/base/Dockerfile.source
Normal file
101
docker/base/Dockerfile.source
Normal file
@@ -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"]
|
||||
@@ -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.
|
||||
>
|
||||
> **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.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. 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.
|
||||
|
||||
### `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`)
|
||||
- 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):**
|
||||
|
||||
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.
|
||||
@@ -84,10 +84,6 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
@@ -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 |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M3` | MiniMax |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`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`).
|
||||
|
||||
## 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
|
||||
@@ -199,6 +195,9 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Config parsing strips unrecognized object keys so a config written by a newer daemon does not brick
|
||||
older read paths such as `paseo daemon status`. Malformed known fields still fail validation.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -173,33 +173,11 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Agent Tool Catalog Measurement
|
||||
|
||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
||||
|
||||
```bash
|
||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
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
|
||||
{
|
||||
"worktree": {
|
||||
@@ -303,8 +281,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.
|
||||
|
||||
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:
|
||||
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
|
||||
@@ -10,7 +10,8 @@ The image source lives in [`docker/`](../docker/).
|
||||
|
||||
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
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- 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
|
||||
|
||||
```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
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=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
|
||||
@@ -216,12 +216,13 @@ pushing a `v*` release tag:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-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
|
||||
from the checked-out source tree and publishes only the exact prerelease image
|
||||
tag for prerelease versions.
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
|
||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
|
||||
@@ -96,18 +96,6 @@ Keep workspace identity and retention outside native-stack `getId` and
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||
with its light default background. A screen-level wrapper can hide that surface
|
||||
while settled, but Android may expose it for one frame when navigation crosses
|
||||
from a nested stack to its parent stack. This is especially visible when an
|
||||
app-wide route such as `/new` opens from a dark workspace.
|
||||
|
||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
|
||||
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).
|
||||
@@ -4,7 +4,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
|
||||
@@ -1,104 +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.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
||||
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
||||
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
||||
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
||||
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
### The crash
|
||||
|
||||
@@ -73,7 +73,7 @@ Anyone who builds software:
|
||||
- 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
|
||||
- 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
|
||||
- Frequent releases (multiple per week)
|
||||
- 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,17 +18,13 @@ 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.
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
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. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. 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`.
|
||||
|
||||
|
||||
@@ -50,9 +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.
|
||||
|
||||
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`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
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).
|
||||
|
||||
@@ -283,7 +281,8 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -9,11 +9,6 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
|
||||
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
|
||||
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
|
||||
an agent cannot restore an oversized tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
@@ -90,19 +90,6 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
|
||||
|
||||
```tsx
|
||||
// Wrong: sharedStyles.row already carries Unistyles metadata.
|
||||
const styles = StyleSheet.create({ row: sharedStyles.row });
|
||||
<View style={styles.row} />;
|
||||
|
||||
// Right: one registered style identity reaches the native view.
|
||||
<View style={sharedStyles.row} />;
|
||||
```
|
||||
|
||||
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
|
||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -21797,9 +21797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28563,9 +28563,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35069,26 +35069,6 @@
|
||||
"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": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
@@ -35152,7 +35132,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36170,12 +36150,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/server": "0.1.106",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/server": "0.1.103",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36421,10 +36401,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36435,7 +36415,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36678,7 +36658,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37574,7 +37554,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37806,20 +37786,19 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38037,15 +38016,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/highlight": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/highlight": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/relay": "0.1.103",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38582,7 +38561,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -58,7 +58,7 @@
|
||||
"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: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",
|
||||
"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",
|
||||
|
||||
@@ -37,91 +37,6 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-markdown-root-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for Markdown root stability test.",
|
||||
});
|
||||
try {
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||
|
||||
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||
const initialText = (await activeBlock.textContent()) ?? "";
|
||||
const activeBlockHandle = await activeBlock.elementHandle();
|
||||
if (!activeBlockHandle) {
|
||||
throw new Error("Expected the active assistant message to contain a block");
|
||||
}
|
||||
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||
if (!markdownRoot) {
|
||||
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
Object.assign(window, {
|
||||
__markdownRootEvidence: evidence,
|
||||
__markdownRootObserver: observer,
|
||||
});
|
||||
}, activeBlockHandle);
|
||||
|
||||
await expect
|
||||
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||
.toBeGreaterThan(initialText.length + 80);
|
||||
|
||||
const evidence = await page.evaluate((root) => {
|
||||
const state = window as typeof window & {
|
||||
__markdownRootEvidence?: {
|
||||
addedNodes: number;
|
||||
characterDataMutations: number;
|
||||
removedNodes: number;
|
||||
};
|
||||
__markdownRootObserver?: MutationObserver;
|
||||
};
|
||||
state.__markdownRootObserver?.disconnect();
|
||||
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||
const message = messages.item(messages.length - 1);
|
||||
const block = message?.lastElementChild;
|
||||
return {
|
||||
...state.__markdownRootEvidence,
|
||||
connected: root.isConnected,
|
||||
sameRoot: block?.firstElementChild === root,
|
||||
};
|
||||
}, markdownRoot);
|
||||
|
||||
await testInfo.attach("markdown-root-stability", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(evidence.connected).toBe(true);
|
||||
expect(evidence.sameRoot).toBe(true);
|
||||
expect(
|
||||
evidence.removedNodes,
|
||||
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -298,24 +266,13 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
layout: "unified",
|
||||
viewMode: "flat",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
}),
|
||||
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
||||
);
|
||||
},
|
||||
{ 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> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
@@ -12,7 +10,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -22,6 +20,10 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
||||
// user-confirmed archive proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
@@ -48,7 +50,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||
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 input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -72,26 +74,6 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -99,7 +81,7 @@ test.describe("Project picker search", () => {
|
||||
await waitForSidebarProjectListReady(page);
|
||||
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 input.fill("paseo-loading-state-no-match");
|
||||
|
||||
@@ -156,21 +138,17 @@ test.describe("Project with no workspaces persists", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
await expect(workspaceRow).toBeVisible({
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await workspaceRow.click();
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
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";
|
||||
|
||||
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.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
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -73,7 +57,7 @@ const test = base.extend<{
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `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
|
||||
// setting this flag before the reload.
|
||||
@@ -89,20 +73,12 @@ const test = base.extend<{
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
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.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
preferences: createAgentPreferences,
|
||||
seedNonce,
|
||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
|
||||
await provide();
|
||||
@@ -116,30 +92,6 @@ const test = base.extend<{
|
||||
},
|
||||
{ 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) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface DesktopBridgeConfig {
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
dialogOpenResult?: string | string[] | null;
|
||||
editorTargets?: DesktopEditorTargetConfig[];
|
||||
editorRecordPath?: string;
|
||||
}
|
||||
@@ -97,7 +96,6 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__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>;
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
events: { on: () => Promise<() => void> };
|
||||
@@ -252,10 +249,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
open: async (options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogOpenCalls.push(options);
|
||||
return cfg.dialogOpenResult ?? null;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
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;
|
||||
}, 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> {
|
||||
await openSettings(page);
|
||||
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,10 +5,10 @@ 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";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||
const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||
@@ -20,31 +20,6 @@ export interface FakeScheduleHostWorkspace {
|
||||
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 {
|
||||
@@ -139,7 +114,6 @@ export async function installFakeScheduleHost(input: {
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
schedules?: FakeScheduleSummary[];
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
@@ -227,7 +201,7 @@ export async function installFakeScheduleHost(input: {
|
||||
ws.send(
|
||||
buildSessionMessage("schedule/list/response", {
|
||||
requestId,
|
||||
schedules: input.schedules ?? [],
|
||||
schedules: [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
@@ -258,24 +232,15 @@ export async function addFakeScheduleHostAndReload(input: {
|
||||
}
|
||||
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));
|
||||
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
seededHost: host,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
extraHosts: EXTRA_HOSTS_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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: "Diagnostics", 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).
|
||||
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);
|
||||
}
|
||||
@@ -39,9 +39,10 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
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 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 });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
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 });
|
||||
}
|
||||
|
||||
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<{
|
||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
@@ -176,7 +158,6 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectThinkingOptionsFit(page);
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
@@ -63,11 +63,7 @@ test.describe("New workspace isolation memory", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
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 selectWorkspaceIsolation(page, "worktree");
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
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> {
|
||||
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 input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
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 {
|
||||
@@ -66,42 +56,6 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
|
||||
.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>> = [];
|
||||
|
||||
@@ -125,114 +79,14 @@ test.describe("Schedules", () => {
|
||||
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 expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,27 +6,13 @@ import {
|
||||
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";
|
||||
};
|
||||
};
|
||||
target: { type: string; config?: { cwd?: string } };
|
||||
}
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
@@ -38,12 +24,7 @@ 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 popup.getByText(label, { exact: true }).click();
|
||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -59,7 +40,6 @@ async function deleteScheduleByName(workspace: SeededWorkspace, name: string): P
|
||||
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();
|
||||
@@ -67,10 +47,6 @@ async function expectScheduleCreatedForProject(input: {
|
||||
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({
|
||||
@@ -81,69 +57,6 @@ async function expectScheduleCreatedForProject(input: {
|
||||
);
|
||||
}
|
||||
|
||||
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>> = [];
|
||||
|
||||
@@ -154,30 +67,10 @@ test.describe("Schedules project target", () => {
|
||||
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 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Project schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
@@ -191,84 +84,29 @@ test.describe("Schedules project target", () => {
|
||||
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 expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).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 expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
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: "Cron" }).click();
|
||||
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,
|
||||
});
|
||||
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
||||
});
|
||||
|
||||
test("clears the selected project and model when the host changes", async ({ page }) => {
|
||||
test("clears the selected model when the chosen project moves to another host", 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));
|
||||
|
||||
@@ -290,126 +128,26 @@ test.describe("Schedules project target", () => {
|
||||
});
|
||||
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);
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
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.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
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 expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
||||
|
||||
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.getByRole("button", { name: /select project/i }).click();
|
||||
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 expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
fakeHost.projectDisplayName,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
||||
|
||||
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 openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectSettingsHeader(page, "Host");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive shortcut", () => {
|
||||
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
test.describe("Worktree archive risk warning", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -97,7 +97,7 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive with worktree backing", () => {
|
||||
test.describe("Worktree archive", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -33,7 +33,9 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
@@ -47,51 +49,11 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const first = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `shared-archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||
|
||||
const siblingPayload = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: first.workspaceDirectory,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!siblingPayload.workspace) {
|
||||
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||
}
|
||||
const sibling = siblingPayload.workspace;
|
||||
expect(sibling.workspaceKind).toBe("worktree");
|
||||
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,6 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
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
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,7 +22,6 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"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:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
|
||||
@@ -152,7 +152,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
@@ -99,7 +98,6 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
const active = useRetainedPanelActive();
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingLoader}>
|
||||
@@ -108,7 +106,6 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
startedAt={inFlightTurnStartedAt}
|
||||
active={active}
|
||||
style={stylesheet.workingElapsed}
|
||||
testID="turn-working-elapsed"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
@@ -51,7 +52,6 @@ import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
@@ -87,7 +87,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
@@ -318,7 +318,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -508,7 +507,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const frozenStreamItemsRef = useRef(streamItems);
|
||||
const frozenStreamHeadRef = useRef(streamHead);
|
||||
if (isActive) {
|
||||
@@ -634,12 +633,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
args={item.text}
|
||||
status={item.status === "ready" ? "completed" : "executing"}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
defaultExpanded={autoExpandReasoning}
|
||||
forceInline={autoExpandReasoning}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[autoExpandReasoning, setInlineDetailsExpanded],
|
||||
[setInlineDetailsExpanded],
|
||||
);
|
||||
|
||||
const renderToolCallItem = useCallback(
|
||||
|
||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -17,8 +23,9 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
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 { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
@@ -27,19 +34,25 @@ import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
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 { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
@@ -52,7 +65,6 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -69,12 +81,10 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
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 { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
@@ -85,11 +95,17 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
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 { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
@@ -388,16 +404,22 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const rowStyle = { flex: 1, flexDirection: "row" } 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";
|
||||
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
selectedAgentId?: string;
|
||||
chromeEnabled?: boolean;
|
||||
}
|
||||
|
||||
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 { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
@@ -452,31 +474,28 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? sidebarChrome : null}
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const surface = (
|
||||
const content = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
@@ -485,38 +504,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<HostChooserModal />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<QuittingOverlay />
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = isCompactLayout ? (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||
) : (
|
||||
surface
|
||||
);
|
||||
if (!isCompactLayout) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
showSidebar,
|
||||
keyboardShortcutsEnabled,
|
||||
}: {
|
||||
showSidebar: boolean;
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
return (
|
||||
<SidebarModelProvider active={showSidebar && isOpen}>
|
||||
{showSidebar ? <LeftSidebar /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
@@ -526,13 +525,131 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
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 (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
{children}
|
||||
</View>
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
@@ -764,6 +881,7 @@ function OpenProjectListener() {
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
@@ -777,7 +895,30 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
pathname === "/schedules" ||
|
||||
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() {
|
||||
@@ -785,15 +926,21 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
const stackScreenOptions = useMemo(
|
||||
() => ({
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
return (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack screenOptions={stackScreenOptions}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -810,7 +957,7 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</ThemedStack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -826,7 +973,7 @@ function WorkspaceRouteNavigationBridge() {
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<MobilePanelsProvider>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
@@ -834,7 +981,7 @@ function AppShell() {
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</MobilePanelsProvider>
|
||||
</SidebarAnimationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -851,27 +998,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
|
||||
// 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
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootAppTree() {
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
@@ -885,18 +1036,6 @@ function RootAppTree() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
@@ -34,14 +33,14 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
</ThemedStack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
areWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
getWorkspaceSelectionKey,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
@@ -187,25 +185,22 @@ function WorkspaceDeck() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const nextMountedSelections = useMemo(
|
||||
() =>
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
const next = pruneMountedWorkspaceSelections({
|
||||
currentSelections: current,
|
||||
activeSelection,
|
||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}),
|
||||
[activeSelection, mountedSelections],
|
||||
);
|
||||
const renderedSelections = useMemo(
|
||||
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
|
||||
[nextMountedSelections],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
|
||||
setMountedSelections(nextMountedSelections);
|
||||
}
|
||||
}, [mountedSelections, nextMountedSelections]);
|
||||
});
|
||||
if (areWorkspaceSelectionListsEqual(current, next)) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
@@ -213,7 +208,7 @@ function WorkspaceDeck() {
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{renderedSelections.map((selection) => {
|
||||
{mountedSelections.map((selection) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
@@ -256,8 +251,8 @@ function WorkspaceDeckEntry({
|
||||
}
|
||||
|
||||
return (
|
||||
<RetainedPanel
|
||||
active={isActive}
|
||||
<View
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
@@ -265,7 +260,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,4 +268,11 @@ const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,38 +1,7 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
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 { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
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()} />;
|
||||
}
|
||||
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
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 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} />;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -36,6 +36,13 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
|
||||
return "Review";
|
||||
}
|
||||
|
||||
const BROWSER_INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
||||
fix: "workspace.browser.annotate.intents.fix",
|
||||
change: "workspace.browser.annotate.intents.change",
|
||||
question: "workspace.browser.annotate.intents.question",
|
||||
approve: "workspace.browser.annotate.intents.approve",
|
||||
};
|
||||
|
||||
function getTextAttachmentSubtitle(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
@@ -89,10 +96,11 @@ export function getWorkspaceAttachmentPillContent(
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
const intent = attachment.attachment.intent;
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface AttachmentMetadata {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind of review feedback the user is attaching to a selected browser
|
||||
* element, sent to the agent alongside the element context.
|
||||
*/
|
||||
export type BrowserAnnotationIntent = "fix" | "change" | "question" | "approve";
|
||||
|
||||
export interface BrowserElementAttachment {
|
||||
url: string;
|
||||
selector: string;
|
||||
@@ -44,6 +50,8 @@ export interface BrowserElementAttachment {
|
||||
children: string[];
|
||||
/** Free-text review note the user wrote about this element, if any. */
|
||||
comment?: string;
|
||||
/** What the user wants the agent to do with this element, if annotated. */
|
||||
intent?: BrowserAnnotationIntent;
|
||||
/**
|
||||
* Cropped screenshot of the selected element, sent to the agent as an image
|
||||
* alongside the textual element context. Persisted via the attachment store;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } 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";
|
||||
@@ -9,6 +8,20 @@ import {
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
removeItem: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
@@ -17,28 +30,9 @@ 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[] = [];
|
||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||
|
||||
public on(
|
||||
@@ -61,107 +55,6 @@ class FakeDaemonClient {
|
||||
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 {
|
||||
@@ -177,69 +70,45 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: {
|
||||
command: "new_tab",
|
||||
args: { url: "https://example.com" },
|
||||
},
|
||||
workspaceId: "/repo",
|
||||
command: { command: "new_tab", args: { workspaceId: "/repo", 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 {
|
||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
return {
|
||||
requestId,
|
||||
ok: true,
|
||||
ok: true as const,
|
||||
result: {
|
||||
command: "list_tabs",
|
||||
command: "list_tabs" as const,
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
return {
|
||||
requestId,
|
||||
ok: true,
|
||||
ok: true as const,
|
||||
result: {
|
||||
command: "list_tabs",
|
||||
command: "list_tabs" as const,
|
||||
tabs: currentBrowserTabs(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function waitForAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
function currentBrowserTabs() {
|
||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||
browserId: browser.browserId,
|
||||
workspaceId: "wks_workspace_a",
|
||||
workspaceId: "/repo",
|
||||
url: browser.url,
|
||||
title: browser.title,
|
||||
isActive: true,
|
||||
@@ -247,268 +116,151 @@ function currentBrowserTabs() {
|
||||
}));
|
||||
}
|
||||
|
||||
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();
|
||||
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
|
||||
const setAgentActiveBrowser = vi.fn(async () => undefined);
|
||||
const registerWorkspaceBrowser = vi.fn(async () => undefined);
|
||||
const ensureResidentBrowserWebview = vi.fn();
|
||||
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
workspaceId: "/repo",
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
throw new Error("Expected workspace key");
|
||||
}
|
||||
const previousFocusedTabId = useWorkspaceLayoutStore
|
||||
if (!workspaceKey) throw new Error("expected workspace key");
|
||||
const focusedTabId = useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||
browser.mount({ serverId: "server-1" });
|
||||
if (!focusedTabId) throw new Error("expected focused tab");
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () =>
|
||||
({
|
||||
browser: {
|
||||
executeAutomationCommand,
|
||||
registerWorkspaceBrowser,
|
||||
setWorkspaceActiveBrowser,
|
||||
setAgentActiveBrowser,
|
||||
},
|
||||
}) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
});
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
|
||||
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 }),
|
||||
}),
|
||||
const payload = client.sentResponses[0]?.payload;
|
||||
expect(payload?.ok).toBe(true);
|
||||
if (!payload?.ok) throw new Error("expected success");
|
||||
expect(payload.result.command).toBe("new_tab");
|
||||
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
|
||||
expect(payload.result.url).toBe("https://example.com");
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toContainEqual(
|
||||
expect.objectContaining({ target: { kind: "browser", browserId: payload.result.browserId } }),
|
||||
);
|
||||
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: {} },
|
||||
},
|
||||
]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout?.root.kind).toBe("pane");
|
||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
||||
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
|
||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
||||
browserId: payload.result.browserId,
|
||||
workspaceId: "/repo",
|
||||
});
|
||||
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
browserId: payload.result.browserId,
|
||||
});
|
||||
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
||||
browserId: payload.result.browserId,
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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({
|
||||
test("returns browser_timeout when the resident webview does not register", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const ensureResidentBrowserWebview = vi.fn();
|
||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs: 1,
|
||||
registrationPollIntervalMs: 1,
|
||||
});
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await waitForRegistrationTimeout();
|
||||
client.receive(browserNewTabRequest());
|
||||
await waitForAsyncWork();
|
||||
|
||||
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(client.sentResponses[0]?.payload).toMatchObject({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_timeout",
|
||||
retryable: true,
|
||||
},
|
||||
]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
});
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
||||
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",
|
||||
);
|
||||
expect(client.sentResponses[0]?.payload).not.toMatchObject({
|
||||
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,
|
||||
},
|
||||
result: { command: "new_tab" },
|
||||
});
|
||||
});
|
||||
|
||||
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
test("wraps browser_new_tab registration bridge errors in a response", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => {
|
||||
throw new Error("IPC registration check failed");
|
||||
});
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
});
|
||||
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));
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
|
||||
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",
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${result.browserId}`,
|
||||
code: "browser_unknown_error",
|
||||
message: "IPC registration check failed",
|
||||
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();
|
||||
test("sends a success response from the desktop bridge", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
ok: true as const,
|
||||
result: { command: "list_tabs" as const, tabs: [] },
|
||||
}));
|
||||
|
||||
browser.receive(request);
|
||||
await flushAsyncWork();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
});
|
||||
|
||||
expect(browser.browser.executedRequests).toEqual([request]);
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
||||
expect(client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
@@ -520,14 +272,14 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing desktop bridge sends browser_unsupported", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ host: null });
|
||||
test("missing bridge sends browser_unsupported", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
||||
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
expect(client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
@@ -535,7 +287,7 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not available in this app runtime.",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
@@ -544,68 +296,80 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
});
|
||||
|
||||
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,
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({
|
||||
browser: {
|
||||
executeAutomationCommand: async () => {
|
||||
throw {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
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,
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({
|
||||
browser: {
|
||||
executeAutomationCommand: async () => {
|
||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||
retryable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("unsubscribe stops handling browser automation requests", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount();
|
||||
test("unsubscribe stops handling requests", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
ok: true as const,
|
||||
result: { command: "list_tabs" as const, tabs: [] },
|
||||
}));
|
||||
const unsubscribe = mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
||||
});
|
||||
|
||||
browser.unmount();
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
unsubscribe();
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(browser.browser.executedRequests).toEqual([]);
|
||||
expect(browser.client.sentResponses).toEqual([]);
|
||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
||||
expect(client.sentResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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 { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
@@ -45,7 +40,7 @@ export function mountBrowserAutomationHandler(
|
||||
options: BrowserAutomationHandlerOptions,
|
||||
): () => void {
|
||||
const getHost = options.getHost ?? getDesktopHost;
|
||||
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
|
||||
return options.client.on("browser.automation.execute.request", (request) => {
|
||||
void handleBrowserAutomationRequest({
|
||||
client: options.client,
|
||||
getHost,
|
||||
@@ -61,9 +56,6 @@ export function mountBrowserAutomationHandler(
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationDaemonClientHandler(
|
||||
@@ -119,32 +111,7 @@ async function handleBrowserAutomationRequest(params: {
|
||||
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;
|
||||
}
|
||||
await rememberAgentBrowserTarget({ request, browserHost });
|
||||
|
||||
if (!executeAutomationCommand) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
@@ -152,7 +119,7 @@ async function handleBrowserAutomationRequest(params: {
|
||||
payload: browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not available in this app runtime.",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -172,127 +139,6 @@ async function handleBrowserAutomationRequest(params: {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -313,11 +159,11 @@ async function openBrowserTabForRequest(params: {
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "new_tab" }
|
||||
>;
|
||||
const workspaceId = request.workspaceId;
|
||||
const workspaceId = request.workspaceId ?? command.args.workspaceId;
|
||||
if (!serverId || !workspaceId) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
code: "browser_no_tab",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
@@ -328,7 +174,7 @@ async function openBrowserTabForRequest(params: {
|
||||
if (!workspaceKey) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
code: "browser_no_tab",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
@@ -338,6 +184,9 @@ async function openBrowserTabForRequest(params: {
|
||||
});
|
||||
|
||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
if (request.agentId) {
|
||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
||||
}
|
||||
|
||||
if (browserHost?.executeAutomationCommand) {
|
||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||
@@ -355,7 +204,7 @@ async function openBrowserTabForRequest(params: {
|
||||
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.`,
|
||||
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
@@ -386,7 +235,7 @@ async function waitForBrowserRegistration(params: {
|
||||
agentId: params.request.agentId,
|
||||
cwd: params.request.cwd,
|
||||
workspaceId: params.workspaceId,
|
||||
command: { command: "list_tabs", args: {} },
|
||||
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
|
||||
});
|
||||
if (payload.ok && payload.result.command === "list_tabs") {
|
||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||
@@ -402,6 +251,28 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function rememberAgentBrowserTarget(input: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
}): Promise<void> {
|
||||
if (!input.request.agentId) {
|
||||
return;
|
||||
}
|
||||
const browserId = readRequestBrowserId(input.request);
|
||||
if (!browserId) {
|
||||
return;
|
||||
}
|
||||
await input.browserHost?.setAgentActiveBrowser?.({ agentId: input.request.agentId, browserId });
|
||||
}
|
||||
|
||||
function readRequestBrowserId(request: BrowserAutomationExecuteRequest): string | null {
|
||||
if (request.browserId) {
|
||||
return request.browserId;
|
||||
}
|
||||
const args = request.command.args as { browserId?: unknown };
|
||||
return typeof args.browserId === "string" && args.browserId.length > 0 ? args.browserId : null;
|
||||
}
|
||||
|
||||
function normalizeBridgePayload(
|
||||
requestId: string,
|
||||
payload: BrowserAutomationResponsePayload,
|
||||
@@ -423,14 +294,14 @@ function normalizeThrownBridgeError(
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not implemented by this app build yet.",
|
||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||
});
|
||||
}
|
||||
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unknown_error",
|
||||
message: message || "Browser automation failed.",
|
||||
message: message || "Desktop browser automation failed.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -221,7 +220,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
...createControlGeometry(theme).controlFocusRingColor,
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
adaptiveInputText: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -232,7 +231,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -450,7 +448,6 @@ export interface AdaptiveModalSheetProps {
|
||||
header: SheetHeader;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
children: ReactNode;
|
||||
/** Sticky footer rendered below the scrollable content. */
|
||||
footer?: ReactNode;
|
||||
@@ -471,7 +468,6 @@ export function AdaptiveModalSheet({
|
||||
header,
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
children,
|
||||
footer,
|
||||
snapPoints,
|
||||
@@ -537,20 +533,6 @@ export function AdaptiveModalSheet({
|
||||
isEnabled: isMobile,
|
||||
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(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -563,53 +545,12 @@ export function AdaptiveModalSheet({
|
||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||
[desktopMaxWidth],
|
||||
);
|
||||
const desktopOverlayStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopOverlay,
|
||||
isWeb && {
|
||||
opacity: isWebClosing ? 0 : 1,
|
||||
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
||||
transitionProperty: "opacity",
|
||||
transitionTimingFunction: "ease",
|
||||
},
|
||||
],
|
||||
[isWebClosing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile || !visible) return;
|
||||
return pushEscHandler(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) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
@@ -618,7 +559,7 @@ export function AdaptiveModalSheet({
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
@@ -673,7 +614,7 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
|
||||
const desktopContent = (
|
||||
<View style={desktopOverlayStyle} testID={testID}>
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.dismiss")}
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
@@ -685,7 +626,7 @@ export function AdaptiveModalSheet({
|
||||
|
||||
// On web, use portal to overlay root for consistent stacking with toasts
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
if (!shouldRenderWeb) return null;
|
||||
if (!visible) return null;
|
||||
return createPortal(desktopContent, getOverlayRoot());
|
||||
}
|
||||
|
||||
@@ -695,7 +636,6 @@ export function AdaptiveModalSheet({
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
onDismiss={notifyNativeModalDismiss}
|
||||
hardwareAccelerated
|
||||
>
|
||||
{desktopContent}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
formatAppDiagnosticHeader,
|
||||
formatDiagnosticSection,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
formatServerInfoSection,
|
||||
redactAppDiagnosticReport,
|
||||
} from "@/diagnostics/app-diagnostic-report";
|
||||
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
|
||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
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(
|
||||
host: HostProfile,
|
||||
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 {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -12,22 +12,21 @@ import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Maximize,
|
||||
Monitor,
|
||||
MousePointer2,
|
||||
PencilRuler,
|
||||
RotateCw,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Wrench,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
@@ -41,7 +40,11 @@ import {
|
||||
useWorkspaceAttachments,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
BrowserAnnotationIntent,
|
||||
BrowserElementAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import {
|
||||
@@ -49,6 +52,7 @@ import {
|
||||
isElectronRuntime,
|
||||
type DesktopBrowserShortcutEvent,
|
||||
} from "@/desktop/host";
|
||||
import { isDev } from "@/constants/platform";
|
||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
||||
import {
|
||||
prepareBrowserWebview,
|
||||
@@ -75,14 +79,25 @@ type WebTextInput = TextInput & {
|
||||
getNativeRef?: () => unknown;
|
||||
};
|
||||
|
||||
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
|
||||
type BrowserElementSelection = Omit<
|
||||
BrowserElementAttachment,
|
||||
"formatted" | "comment" | "intent"
|
||||
> & {
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
interface BrowserElementAnnotation {
|
||||
comment: string;
|
||||
intent: BrowserAnnotationIntent;
|
||||
}
|
||||
|
||||
const BROWSER_ANNOTATION_INTENTS: readonly BrowserAnnotationIntent[] = [
|
||||
"fix",
|
||||
"change",
|
||||
"question",
|
||||
"approve",
|
||||
];
|
||||
|
||||
type DeviceSizeId =
|
||||
| "responsive"
|
||||
| "iphone-se"
|
||||
@@ -208,6 +223,10 @@ function formatElementAttachment(
|
||||
const html = truncateText(selection.outerHTML.trim(), 800);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (annotation) {
|
||||
parts.push(`intent: ${annotation.intent}`);
|
||||
}
|
||||
|
||||
if (selection.reactSource?.fileName) {
|
||||
const loc = [
|
||||
selection.reactSource.fileName,
|
||||
@@ -270,6 +289,7 @@ function buildBrowserElementAttachment(
|
||||
parentChain: selection.parentChain,
|
||||
children: selection.children,
|
||||
...(comment ? { comment } : {}),
|
||||
...(annotation ? { intent: annotation.intent } : {}),
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
formatted: formatElementAttachment(selection, annotation),
|
||||
};
|
||||
@@ -621,11 +641,10 @@ export function BrowserPane({
|
||||
const pendingNavigationUrlRef = useRef<string | null>(null);
|
||||
const domReadyRef = useRef(false);
|
||||
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
|
||||
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
|
||||
const selectorActive = selectorMode !== null;
|
||||
const [selectorActive, setSelectorActive] = useState(false);
|
||||
// Which action the active selector performs on click: open the annotation card
|
||||
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
|
||||
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
|
||||
// ("annotate") or copy the element to the clipboard ("grab").
|
||||
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
|
||||
const toast = useToast();
|
||||
const toastRef = useRef(toast);
|
||||
toastRef.current = toast;
|
||||
@@ -1055,7 +1074,7 @@ export function BrowserPane({
|
||||
[],
|
||||
);
|
||||
|
||||
const screenshotElementToClipboard = useCallback(
|
||||
const grabElementToClipboard = useCallback(
|
||||
async (selection: BrowserElementSelection) => {
|
||||
const text = formatElementAttachment(selection);
|
||||
const copyElement = getDesktopHost()?.browser?.copyElement;
|
||||
@@ -1068,23 +1087,19 @@ export function BrowserPane({
|
||||
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
|
||||
imageDataUrl = dataUrl ?? undefined;
|
||||
} catch (error) {
|
||||
console.warn("[browser-pane] capture element for screenshot failed", error);
|
||||
console.warn("[browser-pane] capture element for grab failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
const copiedMessage = imageDataUrl
|
||||
? t("workspace.browser.controls.screenshotCopied")
|
||||
: t("workspace.browser.controls.elementCopied");
|
||||
|
||||
// Copy via the main process; the renderer's navigator.clipboard rejects
|
||||
// with NotAllowedError because focus is inside the guest <webview>.
|
||||
if (typeof copyElement === "function") {
|
||||
try {
|
||||
const ok = await copyElement({ text, imageDataUrl });
|
||||
if (ok) {
|
||||
toastRef.current?.show(copiedMessage, { variant: "success" });
|
||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
||||
} else {
|
||||
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -1095,12 +1110,10 @@ export function BrowserPane({
|
||||
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
|
||||
try {
|
||||
await Clipboard.setStringAsync(text);
|
||||
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
|
||||
variant: "success",
|
||||
});
|
||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
||||
} catch (error) {
|
||||
console.warn("[browser-pane] clipboard fallback failed", error);
|
||||
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
@@ -1108,8 +1121,8 @@ export function BrowserPane({
|
||||
|
||||
const handleSelectorResult = useCallback(
|
||||
(selection: BrowserElementSelection) => {
|
||||
if (selectorModeRef.current === "screenshot") {
|
||||
void screenshotElementToClipboard(selection);
|
||||
if (selectorModeRef.current === "grab") {
|
||||
void grabElementToClipboard(selection);
|
||||
return;
|
||||
}
|
||||
pendingScreenshotRef.current = undefined;
|
||||
@@ -1119,7 +1132,7 @@ export function BrowserPane({
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
[captureElementScreenshot, screenshotElementToClipboard],
|
||||
[captureElementScreenshot, grabElementToClipboard],
|
||||
);
|
||||
|
||||
const submitAnnotation = useCallback(
|
||||
@@ -1142,15 +1155,15 @@ export function BrowserPane({
|
||||
}, []);
|
||||
|
||||
const startElementSelector = useCallback(
|
||||
(mode: "annotate" | "screenshot") => {
|
||||
(mode: "annotate" | "grab") => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview || !domReadyRef.current) return;
|
||||
// Annotate needs a workspace scope to attach to; screenshot only copies.
|
||||
// Annotate needs a workspace scope to attach to; grab only copies.
|
||||
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
||||
selectorModeRef.current = mode;
|
||||
pendingScreenshotRef.current = undefined;
|
||||
setPendingSelection(null);
|
||||
setSelectorMode(mode);
|
||||
setSelectorActive(true);
|
||||
|
||||
const js = `
|
||||
(function() {
|
||||
@@ -1376,11 +1389,11 @@ export function BrowserPane({
|
||||
const poll = startSelectorResultPolling({
|
||||
webview,
|
||||
onSelection: handleSelectorResult,
|
||||
onDone: () => setSelectorMode(null),
|
||||
onDone: () => setSelectorActive(false),
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
window.clearInterval(poll);
|
||||
setSelectorMode(null);
|
||||
setSelectorActive(false);
|
||||
if (webviewRef.current !== webview || !domReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1389,10 +1402,10 @@ export function BrowserPane({
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
setSelectorMode(null);
|
||||
setSelectorActive(false);
|
||||
});
|
||||
} catch {
|
||||
setSelectorMode(null);
|
||||
setSelectorActive(false);
|
||||
}
|
||||
},
|
||||
[handleSelectorResult, workspaceAttachmentScopeKey],
|
||||
@@ -1400,7 +1413,7 @@ export function BrowserPane({
|
||||
|
||||
const cancelElementSelector = useCallback(() => {
|
||||
const webview = webviewRef.current;
|
||||
setSelectorMode(null);
|
||||
setSelectorActive(false);
|
||||
if (webview && domReadyRef.current) {
|
||||
try {
|
||||
clearWebviewSelector(webview);
|
||||
@@ -1457,12 +1470,12 @@ export function BrowserPane({
|
||||
startElementSelector("annotate");
|
||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||
|
||||
const handleToggleScreenshot = useCallback(() => {
|
||||
const handleToggleGrab = useCallback(() => {
|
||||
if (selectorActive) {
|
||||
cancelElementSelector();
|
||||
return;
|
||||
}
|
||||
startElementSelector("screenshot");
|
||||
startElementSelector("grab");
|
||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||
|
||||
const handleOpenDevTools = useCallback(() => {
|
||||
@@ -1508,21 +1521,13 @@ export function BrowserPane({
|
||||
],
|
||||
[browser?.canGoForward],
|
||||
);
|
||||
const annotateIconButtonStyle = useCallback(
|
||||
const selectorIconButtonStyle = useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||
styles.iconButton,
|
||||
selectorMode === "annotate" && styles.selectorActiveButton,
|
||||
selectorActive && styles.selectorActiveButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
],
|
||||
[selectorMode],
|
||||
);
|
||||
const screenshotIconButtonStyle = useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||
styles.iconButton,
|
||||
selectorMode === "screenshot" && styles.selectorActiveButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
],
|
||||
[selectorMode],
|
||||
[selectorActive],
|
||||
);
|
||||
|
||||
const devicePreset = useMemo(
|
||||
@@ -1628,46 +1633,36 @@ export function BrowserPane({
|
||||
onSelect={setDeviceSizeId}
|
||||
triggerStyle={baseIconButtonStyle}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={t("workspace.browser.controls.openDevTools")}
|
||||
onPress={handleOpenDevTools}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
<Wrench size={16} color={theme.colors.foregroundMuted} />
|
||||
</ToolbarButton>
|
||||
{isDev ? (
|
||||
<ToolbarButton
|
||||
label={t("workspace.browser.controls.openDevTools")}
|
||||
onPress={handleOpenDevTools}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
label={
|
||||
selectorMode === "annotate"
|
||||
selectorActive
|
||||
? t("workspace.browser.controls.cancelSelector")
|
||||
: t("workspace.browser.controls.annotateElement")
|
||||
: t("workspace.browser.controls.selectElement")
|
||||
}
|
||||
active={selectorMode === "annotate"}
|
||||
active={selectorActive}
|
||||
onPress={handleToggleElementSelector}
|
||||
style={annotateIconButtonStyle}
|
||||
style={selectorIconButtonStyle}
|
||||
>
|
||||
<MousePointer2
|
||||
size={16}
|
||||
color={
|
||||
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||
}
|
||||
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={
|
||||
selectorMode === "screenshot"
|
||||
? t("workspace.browser.controls.cancelSelector")
|
||||
: t("workspace.browser.controls.screenshotElement")
|
||||
}
|
||||
active={selectorMode === "screenshot"}
|
||||
onPress={handleToggleScreenshot}
|
||||
style={screenshotIconButtonStyle}
|
||||
label={t("workspace.browser.controls.grabElement")}
|
||||
onPress={handleToggleGrab}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
<Camera
|
||||
size={16}
|
||||
color={
|
||||
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
<Copy size={16} color={theme.colors.foregroundMuted} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1695,6 +1690,45 @@ export function BrowserPane({
|
||||
);
|
||||
}
|
||||
|
||||
const INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
||||
fix: "workspace.browser.annotate.intents.fix",
|
||||
change: "workspace.browser.annotate.intents.change",
|
||||
question: "workspace.browser.annotate.intents.question",
|
||||
approve: "workspace.browser.annotate.intents.approve",
|
||||
};
|
||||
|
||||
function IntentChip({
|
||||
active,
|
||||
intent,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
active: boolean;
|
||||
intent: BrowserAnnotationIntent;
|
||||
label: string;
|
||||
onSelect: (intent: BrowserAnnotationIntent) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(intent);
|
||||
}, [intent, onSelect]);
|
||||
const chipStyle = useMemo(() => [styles.intentChip, active && styles.intentChipActive], [active]);
|
||||
const textStyle = useMemo(
|
||||
() => [styles.intentChipText, active && styles.intentChipTextActive],
|
||||
[active],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
style={chipStyle}
|
||||
>
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserElementAnnotationCard({
|
||||
selection,
|
||||
onSubmit,
|
||||
@@ -1706,11 +1740,14 @@ function BrowserElementAnnotationCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [comment, setComment] = useState("");
|
||||
const [intent, setIntent] = useState<BrowserAnnotationIntent>("fix");
|
||||
const commentRef = useRef(comment);
|
||||
commentRef.current = comment;
|
||||
const intentRef = useRef(intent);
|
||||
intentRef.current = intent;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit({ comment: commentRef.current });
|
||||
onSubmit({ comment: commentRef.current, intent: intentRef.current });
|
||||
}, [onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1755,6 +1792,17 @@ function BrowserElementAnnotationCard({
|
||||
<Text numberOfLines={1} style={styles.annotationElement}>
|
||||
{elementLabel}
|
||||
</Text>
|
||||
<View style={styles.annotationIntents}>
|
||||
{BROWSER_ANNOTATION_INTENTS.map((option) => (
|
||||
<IntentChip
|
||||
key={option}
|
||||
active={option === intent}
|
||||
intent={option}
|
||||
label={t(INTENT_LABEL_KEYS[option])}
|
||||
onSelect={setIntent}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<ThemedAnnotationInput
|
||||
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
|
||||
autoFocus
|
||||
@@ -1766,12 +1814,24 @@ function BrowserElementAnnotationCard({
|
||||
value={comment}
|
||||
/>
|
||||
<View style={styles.annotationActions}>
|
||||
<Button variant="ghost" size="sm" onPress={onCancel}>
|
||||
{t("workspace.browser.annotate.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={handleSubmit}>
|
||||
{t("workspace.browser.annotate.submit")}
|
||||
</Button>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onCancel}
|
||||
style={styles.annotationSecondaryButton}
|
||||
>
|
||||
<Text style={styles.annotationSecondaryText}>
|
||||
{t("workspace.browser.annotate.cancel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={handleSubmit}
|
||||
style={styles.annotationPrimaryButton}
|
||||
>
|
||||
<Text style={styles.annotationPrimaryText}>
|
||||
{t("workspace.browser.annotate.submit")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1926,7 +1986,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
annotationElement: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
annotationIntents: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
intentChip: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
intentChipActive: {
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: `${String(theme.colors.accent)}20`,
|
||||
},
|
||||
intentChipText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
intentChipTextActive: {
|
||||
color: theme.colors.accent,
|
||||
},
|
||||
annotationInput: {
|
||||
minHeight: 64,
|
||||
@@ -1945,6 +2029,27 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
annotationSecondaryButton: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
annotationSecondaryText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
annotationPrimaryButton: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
annotationPrimaryText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.accentForeground ?? "#ffffff",
|
||||
},
|
||||
unavailableState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -2,66 +2,33 @@ 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");
|
||||
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
|
||||
const host = document.createElement("div");
|
||||
const webview = document.createElement("webview");
|
||||
visibleHost.appendChild(webview);
|
||||
document.body.appendChild(visibleHost);
|
||||
host.appendChild(webview);
|
||||
document.body.appendChild(host);
|
||||
|
||||
releaseResidentBrowserWebview("browser-a", webview);
|
||||
|
||||
const host = residentHost();
|
||||
expect(visibleHost.children).toHaveLength(0);
|
||||
expect(Array.from(host.children)).toEqual([webview]);
|
||||
expect(host.children).toHaveLength(0);
|
||||
expect(webview.isConnected).toBe(true);
|
||||
expectPermanentHostParking(host);
|
||||
expectResidentWebviewParking(webview);
|
||||
expect(webview.style.width).toBe("1280px");
|
||||
expect(webview.style.height).toBe("800px");
|
||||
|
||||
const reused = takeResidentBrowserWebview("browser-a");
|
||||
|
||||
expect(reused).toBe(webview);
|
||||
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||
@@ -77,113 +44,6 @@ describe("resident browser webviews", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -22,84 +21,42 @@ 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);
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-20000px";
|
||||
host.style.top = "0";
|
||||
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||
host.style.overflow = "hidden";
|
||||
host.style.opacity = "0";
|
||||
host.style.pointerEvents = "none";
|
||||
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 element as HTMLElement;
|
||||
}
|
||||
}
|
||||
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`;
|
||||
function applyResidentWebviewStyle(webview: HTMLElement): void {
|
||||
webview.style.display = "block";
|
||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
webview.style.height = `${RESIDENT_VIEWPORT_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(
|
||||
@@ -131,15 +88,11 @@ export function ensureResidentBrowserWebview(input: {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -161,7 +114,6 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
@@ -177,33 +129,10 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||
applyResidentWebviewStyle(webview);
|
||||
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) {
|
||||
@@ -212,7 +141,6 @@ export function removeResidentBrowserWebview(browserId: string): void {
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||
resident?.remove();
|
||||
}
|
||||
|
||||
@@ -221,6 +149,5 @@ export function clearResidentBrowserWebviewsForTests(): void {
|
||||
webview.remove();
|
||||
}
|
||||
residentWebviewsByBrowserId.clear();
|
||||
residentWebviewSizesByBrowserId.clear();
|
||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||
}
|
||||
|
||||
@@ -4,39 +4,24 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
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 { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
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;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
|
||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||
|
||||
function noop() {}
|
||||
@@ -62,67 +47,22 @@ function drillDownRowStyle({
|
||||
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_MAX_HEIGHT = 400;
|
||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||
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 =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
@@ -149,8 +89,6 @@ interface CombinedModelSelectorProps {
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
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
|
||||
@@ -211,7 +149,9 @@ function ModelRow({
|
||||
onPress: () => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
@@ -222,8 +162,8 @@ function ModelRow({
|
||||
);
|
||||
|
||||
const leadingSlot = useMemo(
|
||||
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
||||
[row.provider],
|
||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
@@ -238,10 +178,32 @@ function ModelRow({
|
||||
}
|
||||
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>
|
||||
) : 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 (
|
||||
@@ -343,7 +305,9 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
||||
}
|
||||
|
||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProvIcon = getProviderIcon(provider.id);
|
||||
const selection = provider.modelSelection;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -363,16 +327,18 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
} else if (selection.kind === "loading") {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<View style={styles.rowSpinner}>
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
</View>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
stateNode = (
|
||||
<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>
|
||||
</View>
|
||||
);
|
||||
@@ -380,11 +346,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
|
||||
return (
|
||||
<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>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
{stateNode}
|
||||
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -480,13 +446,14 @@ function ProviderErrorEmptyState({
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => {
|
||||
onRetryProvider?.(providerId);
|
||||
}, [onRetryProvider, providerId]);
|
||||
return (
|
||||
<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>
|
||||
{onRetryProvider ? (
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
@@ -510,6 +477,7 @@ function SelectorContent({
|
||||
onRetryProvider,
|
||||
isRetryingProvider,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
const selectedViewProvider = useMemo(
|
||||
@@ -533,7 +501,7 @@ function SelectorContent({
|
||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||
const 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>
|
||||
</View>
|
||||
);
|
||||
@@ -546,9 +514,11 @@ function SelectorContent({
|
||||
if (drillSelection.kind === "loading") {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.rowSpinner}>
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
</View>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -615,10 +585,9 @@ export function CombinedModelSelector({
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
desktopPlacement,
|
||||
desktopMinWidth,
|
||||
triggerFill = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -674,6 +643,7 @@ export function CombinedModelSelector({
|
||||
);
|
||||
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
return resolveSelectedModelLabel({
|
||||
@@ -780,6 +750,7 @@ export function CombinedModelSelector({
|
||||
if (view.kind === "all") {
|
||||
return { title: t("modelSelector.title") };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openProviderSettings}
|
||||
@@ -792,12 +763,17 @@ export function CombinedModelSelector({
|
||||
})}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<HeaderSettingsIcon disabled={!serverId} />
|
||||
<Settings
|
||||
size={theme.iconSize.sm}
|
||||
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
return {
|
||||
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 },
|
||||
actions: headerActions,
|
||||
search: {
|
||||
@@ -813,10 +789,15 @@ export function CombinedModelSelector({
|
||||
singleProviderView,
|
||||
serverId,
|
||||
openProviderSettings,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchResetKey,
|
||||
t,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foreground,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -854,8 +835,8 @@ export function CombinedModelSelector({
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{hasSelectedProvider ? (
|
||||
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
@@ -869,8 +850,8 @@ export function CombinedModelSelector({
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement={desktopPlacement}
|
||||
desktopMinWidth={desktopMinWidth}
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={360}
|
||||
desktopFixedHeight={desktopFixedHeight}
|
||||
header={sheetHeader}
|
||||
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
||||
@@ -891,7 +872,7 @@ export function CombinedModelSelector({
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</View>
|
||||
)}
|
||||
@@ -1063,10 +1044,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
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 { useWorkspace } from "@/stores/session-store-hooks";
|
||||
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 { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useOpenFileExplorerGesture({
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ContextWindowMeter({
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage().catch(() => {});
|
||||
void refreshProviderUsage();
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -30,7 +30,9 @@ export function DiffScroll({
|
||||
const scrollId = useId();
|
||||
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
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,16 +29,16 @@ import {
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ExplorerTab,
|
||||
} 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 { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
@@ -91,11 +91,25 @@ export function CompactExplorerSidebar({
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: true,
|
||||
});
|
||||
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
@@ -108,40 +122,184 @@ export function CompactExplorerSidebar({
|
||||
[isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
mobilePanelGestureAnimatingRef.current = true;
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
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(
|
||||
() => [
|
||||
explorerStaticStyles.mobileSidebar,
|
||||
{
|
||||
width: windowWidth,
|
||||
paddingTop: insets.top,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarAnimatedStyle,
|
||||
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 (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
</RetainedPanelActivity>
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,6 +604,17 @@ function PrTabContent({
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...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: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
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 type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
@@ -55,6 +55,8 @@ const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "size" },
|
||||
];
|
||||
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -127,7 +129,7 @@ function TreeRowItem({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
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,
|
||||
],
|
||||
[depth, isSelected, theme.spacing],
|
||||
@@ -141,6 +143,11 @@ function TreeRowItem({
|
||||
onDownloadEntry(entry);
|
||||
}, [onDownloadEntry, entry]);
|
||||
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, isExpanded && styles.chevronExpanded],
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
const copyLeading = useMemo(
|
||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
@@ -152,7 +159,7 @@ function TreeRowItem({
|
||||
|
||||
return (
|
||||
<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.entryIcon}>
|
||||
{(() => {
|
||||
@@ -160,7 +167,11 @@ function TreeRowItem({
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
return (
|
||||
<View style={chevronStyle}>
|
||||
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
@@ -1138,6 +1149,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
indentGuide: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
entryInfo: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
@@ -1145,6 +1163,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
chevron: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
entryIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
@@ -1274,3 +1302,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
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,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
|
||||
@@ -414,7 +414,7 @@ export function FilePane({
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
||||
// gate itself lives in isFileQueryEnabled.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const isAppVisible = useAppVisible();
|
||||
|
||||
const query = useQuery({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
|
||||
|
||||
export function getHostPickerLabel(
|
||||
hosts: Array<{ label: string; serverId: string }>,
|
||||
|
||||
@@ -11,16 +11,10 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
} from "./host-picker-constants";
|
||||
|
||||
export {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
};
|
||||
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
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({
|
||||
active,
|
||||
selected,
|
||||
@@ -137,12 +125,12 @@ function SystemHostPickerOption({
|
||||
active: boolean;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
kind: "add" | "all" | "enableBuiltInDaemon";
|
||||
kind: "add" | "all";
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
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(
|
||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
@@ -170,13 +158,11 @@ export interface HostPickerProps {
|
||||
includeAllHost?: boolean;
|
||||
includeAddHost?: boolean;
|
||||
onAddHost?: () => void;
|
||||
includeEnableBuiltInDaemon?: boolean;
|
||||
onEnableBuiltInDaemon?: () => void;
|
||||
showActiveConnection?: boolean;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
searchable?: boolean;
|
||||
title?: string;
|
||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
desktopMinWidth?: number;
|
||||
addHostTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
@@ -193,13 +179,11 @@ export function HostPicker({
|
||||
includeAllHost,
|
||||
includeAddHost,
|
||||
onAddHost,
|
||||
includeEnableBuiltInDaemon,
|
||||
onEnableBuiltInDaemon,
|
||||
showActiveConnection,
|
||||
onOpenHostSettings,
|
||||
searchable,
|
||||
title,
|
||||
desktopPlacement = "bottom-start",
|
||||
desktopPlacement = "top-start",
|
||||
desktopMinWidth,
|
||||
addHostTestID,
|
||||
hostOptionTestID,
|
||||
@@ -215,13 +199,8 @@ export function HostPicker({
|
||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||
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 (includeEnableBuiltInDaemon)
|
||||
hostOptions.push({
|
||||
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
label: "Enable built-in daemon",
|
||||
});
|
||||
return hostOptions;
|
||||
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
|
||||
}, [orderedHosts, includeAllHost, includeAddHost]);
|
||||
|
||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||
|
||||
@@ -229,14 +208,12 @@ export function HostPicker({
|
||||
(id: string) => {
|
||||
if (id === ADD_HOST_OPTION_ID) {
|
||||
onAddHost?.();
|
||||
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||
onEnableBuiltInDaemon?.();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
|
||||
[onAddHost, onOpenChange, onSelect],
|
||||
);
|
||||
|
||||
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 (
|
||||
<HostPickerOption
|
||||
serverId={option.id}
|
||||
|
||||
@@ -21,7 +21,13 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -32,18 +38,19 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
useSidebarWorkspacesList,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
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 { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -55,8 +62,7 @@ import {
|
||||
usePanelStore,
|
||||
} from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
@@ -73,21 +79,25 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string;
|
||||
}
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
statusGroups: StatusGroup[];
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
handleRefresh: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
@@ -113,6 +123,7 @@ interface SidebarLabels {
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
isOpen: boolean;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
handleViewSchedulesNavigate: () => void;
|
||||
@@ -125,7 +136,11 @@ interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
handleViewSchedules: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar() {
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
selectedAgentId: _selectedAgentId,
|
||||
}: LeftSidebarProps) {
|
||||
void _selectedAgentId;
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -136,19 +151,22 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
groupMode,
|
||||
shortcutModel,
|
||||
} = useSidebarModel();
|
||||
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
||||
} = useSidebarWorkspacesList({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: workspacePlacements,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -239,9 +257,8 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -257,39 +274,36 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -371,7 +385,6 @@ function SidebarHostPicker({
|
||||
showActiveConnection
|
||||
onOpenHostSettings={onOpenHostSettings}
|
||||
searchable
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={240}
|
||||
addHostTestID="sidebar-host-add"
|
||||
hostOptionTestID={sidebarHostOptionTestID}
|
||||
@@ -542,9 +555,8 @@ function SidebarFooter({
|
||||
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -563,6 +575,7 @@ function MobileSidebar({
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
handleViewSchedulesNavigate,
|
||||
@@ -570,121 +583,269 @@ function MobileSidebar({
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
const {
|
||||
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(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [closeSidebar, handleViewMoreNavigate]);
|
||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||
|
||||
const handleViewSchedules = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewSchedulesNavigate();
|
||||
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
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(
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
paddingBottom: insetsBottom,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
}),
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
||||
[windowWidth, insetsTop, insetsBottom],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
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 (
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarInsetStyle}
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropStyle} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
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>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -808,9 +969,8 @@ function DesktopSidebar({
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -898,6 +1058,17 @@ function WorkspacesSectionHeader() {
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
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: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
|
||||
@@ -692,39 +692,33 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
interface LiveElapsedProps {
|
||||
startedAt: Date;
|
||||
active?: boolean;
|
||||
style?: StyleProp<TextStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks every second to render an elapsed duration. Isolated from parents so
|
||||
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
|
||||
* only this component re-renders on each tick.
|
||||
*/
|
||||
export const LiveElapsed = memo(function LiveElapsed({
|
||||
startedAt,
|
||||
active = true,
|
||||
style,
|
||||
testID,
|
||||
}: LiveElapsedProps) {
|
||||
const startedAtMs = startedAt.getTime();
|
||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||||
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
const handle = setInterval(() => {
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
}, 1000);
|
||||
}, 100);
|
||||
return () => clearInterval(handle);
|
||||
}, [active, startedAtMs]);
|
||||
}, [startedAtMs]);
|
||||
|
||||
return (
|
||||
<Text style={style} testID={testID}>
|
||||
{formatDuration(visibleElapsedMs)}
|
||||
{formatDuration(elapsedMs)}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
@@ -3044,8 +3038,6 @@ interface ToolCallProps {
|
||||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||
onOpenFilePath?: (filePath: string) => void;
|
||||
defaultExpanded?: boolean;
|
||||
forceInline?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
@@ -3062,14 +3054,11 @@ export const ToolCall = memo(function ToolCall({
|
||||
onInlineDetailsHoverChange,
|
||||
onInlineDetailsExpandedChange,
|
||||
onOpenFilePath,
|
||||
defaultExpanded,
|
||||
forceInline = false,
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const shouldRenderInline = !isMobile || forceInline;
|
||||
|
||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||
if (detail) {
|
||||
@@ -3107,7 +3096,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [presentation.openFilePath, onOpenFilePath]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!shouldRenderInline) {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
@@ -3120,7 +3109,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
isMobile,
|
||||
openToolCall,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
@@ -3131,22 +3120,22 @@ export const ToolCall = memo(function ToolCall({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
return;
|
||||
}
|
||||
onInlineDetailsHoverChange(false);
|
||||
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
|
||||
}, [isExpanded, isMobile, onInlineDetailsHoverChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsExpandedChange) {
|
||||
return;
|
||||
}
|
||||
if (!shouldRenderInline) {
|
||||
if (isMobile) {
|
||||
onInlineDetailsExpandedChange(false);
|
||||
return;
|
||||
}
|
||||
onInlineDetailsExpandedChange(isExpanded);
|
||||
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
|
||||
}, [isExpanded, isMobile, onInlineDetailsExpandedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsExpandedChange) {
|
||||
@@ -3159,7 +3148,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
// Render inline details for desktop
|
||||
const renderDetails = useCallback(() => {
|
||||
if (!shouldRenderInline) return null;
|
||||
if (isMobile) return null;
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
detail={effectiveDetail}
|
||||
@@ -3168,7 +3157,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
@@ -3186,10 +3175,10 @@ export const ToolCall = memo(function ToolCall({
|
||||
label={presentation.displayName}
|
||||
secondaryLabel={presentation.summary}
|
||||
icon={presentation.icon}
|
||||
isExpanded={shouldRenderInline && isExpanded}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||||
onOpenFile={handleOpenFile}
|
||||
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
|
||||
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
@@ -3211,7 +3200,5 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) 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;
|
||||
}
|
||||
|
||||
@@ -9,16 +9,22 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function MarkdownInlineText({
|
||||
textKey,
|
||||
inheritedStyle,
|
||||
ruleStyle,
|
||||
children,
|
||||
}: {
|
||||
textKey: string;
|
||||
inheritedStyle: StyleProp<TextStyle>;
|
||||
ruleStyle: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
||||
return <Text style={style}>{children}</Text>;
|
||||
return (
|
||||
<Text key={textKey} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({
|
||||
@@ -33,10 +39,12 @@ function MarkdownListItemContent({
|
||||
}
|
||||
|
||||
function MarkdownParagraph({
|
||||
textKey,
|
||||
paragraphStyle,
|
||||
isLastChild,
|
||||
children,
|
||||
}: {
|
||||
textKey: string;
|
||||
paragraphStyle: StyleProp<ViewStyle>;
|
||||
isLastChild: boolean;
|
||||
children: ReactNode;
|
||||
@@ -45,7 +53,11 @@ function MarkdownParagraph({
|
||||
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
||||
[paragraphStyle, isLastChild],
|
||||
);
|
||||
return <View style={style}>{children}</View>;
|
||||
return (
|
||||
<View key={textKey} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function createPlanMarkdownRules() {
|
||||
@@ -57,7 +69,11 @@ function createPlanMarkdownRules() {
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
@@ -69,7 +85,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.textgroup}
|
||||
>
|
||||
@@ -84,7 +100,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_block}
|
||||
>
|
||||
@@ -98,7 +114,11 @@ function createPlanMarkdownRules() {
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.fence}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
@@ -110,7 +130,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_inline}
|
||||
>
|
||||
@@ -163,7 +183,7 @@ function createPlanMarkdownRules() {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<MarkdownParagraph
|
||||
key={node.key}
|
||||
textKey={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
isLastChild={isLastChild}
|
||||
>
|
||||
|
||||
@@ -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 { shortenPath } from "@/utils/shorten-path";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
|
||||
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
||||
|
||||
interface PathRowProps {
|
||||
@@ -156,36 +155,31 @@ export function ProjectPickerModal() {
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return { query: debouncedQuery, paths: [] };
|
||||
}
|
||||
if (!client) return [];
|
||||
const result = await client.getDirectorySuggestions({
|
||||
query: debouncedQuery,
|
||||
includeDirectories: true,
|
||||
includeFiles: false,
|
||||
limit: 30,
|
||||
});
|
||||
return {
|
||||
query: debouncedQuery,
|
||||
paths:
|
||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
|
||||
[],
|
||||
};
|
||||
return (
|
||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
|
||||
);
|
||||
},
|
||||
enabled: Boolean(client) && isConnected && open,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const currentSuggestions =
|
||||
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
|
||||
return buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: currentSuggestions?.paths ?? [],
|
||||
query,
|
||||
});
|
||||
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[directorySuggestionsQuery.data, query, recommendedPaths],
|
||||
);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
const isSearching =
|
||||
hasQuery &&
|
||||
@@ -219,8 +213,6 @@ export function ProjectPickerModal() {
|
||||
}
|
||||
|
||||
setOpenErrorReason(getOpenProjectFailureReason(result));
|
||||
} catch {
|
||||
setOpenErrorReason("open_failed");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -240,10 +232,6 @@ export function ProjectPickerModal() {
|
||||
setOpenErrorReason(null);
|
||||
}, []);
|
||||
|
||||
const handleBrowseError = useCallback(() => {
|
||||
setOpenErrorReason("open_failed");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
@@ -274,7 +262,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
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") {
|
||||
event.preventDefault();
|
||||
@@ -282,6 +270,12 @@ export function ProjectPickerModal() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (options.length === 0) return;
|
||||
event.preventDefault();
|
||||
@@ -297,7 +291,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [close, open, options.length]);
|
||||
}, [close, open, options.length, submitActiveOption]);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => [
|
||||
@@ -336,7 +330,6 @@ export function ProjectPickerModal() {
|
||||
<View style={panelStyle}>
|
||||
<View style={headerStyle}>
|
||||
<TextInput
|
||||
testID="project-picker-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={handleChangeQuery}
|
||||
@@ -350,12 +343,6 @@ export function ProjectPickerModal() {
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={submitActiveOption}
|
||||
/>
|
||||
<ProjectPickerBrowseButton
|
||||
serverId={serverId}
|
||||
disabled={isSubmitting}
|
||||
onSelect={handleSelectPath}
|
||||
onError={handleBrowseError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ProjectPickerResults
|
||||
@@ -396,15 +383,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.lg,
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
|
||||
it("puts an absolute path row first", () => {
|
||||
const options = buildProjectPickerOptions({
|
||||
recommendedPaths: ["/repo/api"],
|
||||
serverPaths: ["/repo/api"],
|
||||
serverPaths: [],
|
||||
query: "/repo",
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
|
||||
it("puts a home-relative path row first", () => {
|
||||
const options = buildProjectPickerOptions({
|
||||
recommendedPaths: ["/Users/mo/src/api"],
|
||||
serverPaths: ["/Users/mo/src/api"],
|
||||
serverPaths: [],
|
||||
query: "~/src",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
const RetainedPanelActiveContext = createContext(true);
|
||||
|
||||
export function useRetainedPanelActive(): boolean {
|
||||
return useContext(RetainedPanelActiveContext);
|
||||
}
|
||||
|
||||
interface RetainedPanelProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
interface RetainedPanelActivityProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RetainedPanelActivity({ active, children }: RetainedPanelActivityProps) {
|
||||
const parentActive = useRetainedPanelActive();
|
||||
return (
|
||||
<RetainedPanelActiveContext value={parentActive && active}>
|
||||
{children}
|
||||
</RetainedPanelActiveContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps expensive panel state mounted without letting an inactive panel render
|
||||
* on screen. The stable, non-collapsible native root is intentional: retained
|
||||
* panels must not detach or reparent their native descendants when visibility
|
||||
* changes.
|
||||
*/
|
||||
export const RetainedPanel = memo(function RetainedPanel({
|
||||
active,
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: RetainedPanelProps) {
|
||||
const visibleStyle = StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(styles.root, style);
|
||||
const panelStyle = active
|
||||
? visibleStyle
|
||||
: StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(visibleStyle, styles.hidden);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={active}>
|
||||
<View
|
||||
collapsable={false}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={panelStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
hidden: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
@@ -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,140 +1,366 @@
|
||||
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType } 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 { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
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";
|
||||
describeCron,
|
||||
everyMsToParts,
|
||||
type IntervalUnit,
|
||||
partsToEveryMs,
|
||||
validateCron,
|
||||
} from "@/utils/schedule-format";
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||
type CadenceMode = ScheduleCadence["type"];
|
||||
|
||||
interface CronPreset {
|
||||
label: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
|
||||
// through describeCron() so the chip and the live preview agree.
|
||||
const CRON_PRESETS: CronPreset[] = [
|
||||
{ label: "Every hour", expression: "0 * * * *" },
|
||||
{ label: "Daily 9:00", expression: "0 9 * * *" },
|
||||
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
|
||||
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
|
||||
];
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: "every" as const, label: "Interval" },
|
||||
{ value: "cron" as const, label: "Cron" },
|
||||
];
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
{ value: "minutes" as const, label: "Minutes" },
|
||||
{ value: "hours" as const, label: "Hours" },
|
||||
{ value: "days" as const, label: "Days" },
|
||||
];
|
||||
|
||||
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
|
||||
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
|
||||
|
||||
const UNIT_NOUN: Record<IntervalUnit, string> = {
|
||||
minutes: "minute",
|
||||
hours: "hour",
|
||||
days: "day",
|
||||
};
|
||||
|
||||
function describeInterval(value: number, unit: IntervalUnit): string {
|
||||
const noun = UNIT_NOUN[unit];
|
||||
if (value === 1) {
|
||||
return `Runs every ${noun}`;
|
||||
}
|
||||
return `Runs every ${value} ${noun}s`;
|
||||
}
|
||||
|
||||
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}`,
|
||||
}));
|
||||
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
const mode = value.type;
|
||||
|
||||
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],
|
||||
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
|
||||
// once from the incoming cadence via lazy state initializers and bump
|
||||
// resetKey only when we change the content ourselves (mode switch, preset
|
||||
// chip) — never on every keystroke.
|
||||
const [intervalValueText, setIntervalValueText] = useState(() =>
|
||||
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
|
||||
);
|
||||
const selectedPresetId = resolveCronPresetId(currentCadence);
|
||||
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
|
||||
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
|
||||
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
|
||||
);
|
||||
const [cronText, setCronText] = useState(() =>
|
||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||
);
|
||||
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const handlePresetChange = useCallback(
|
||||
(presetId: string) => {
|
||||
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
|
||||
if (!preset) {
|
||||
// Remember the cron expression the user had so toggling Interval -> Cron and
|
||||
// back does not discard a blanked-out field. Interval mode rebuilds straight
|
||||
// from the live numeric value + unit, so it needs no equivalent ref.
|
||||
const lastCronExpression = useRef(
|
||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||
);
|
||||
|
||||
const parsedIntervalValue = useMemo(() => {
|
||||
const parsed = Number.parseInt(intervalValueText, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
}, [intervalValueText]);
|
||||
|
||||
const emitInterval = useCallback(
|
||||
(rawValue: number, unit: IntervalUnit) => {
|
||||
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const emitCron = useCallback(
|
||||
(expression: string) => {
|
||||
lastCronExpression.current = expression;
|
||||
onChange({ type: "cron", expression });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(nextMode: CadenceMode) => {
|
||||
if (nextMode === mode) {
|
||||
return;
|
||||
}
|
||||
setCronText(preset.expression);
|
||||
bumpFieldResetKey();
|
||||
onChange(buildCronCadence(preset.expression, cronTimeZone));
|
||||
if (nextMode === "every") {
|
||||
emitInterval(parsedIntervalValue, intervalUnit);
|
||||
} else {
|
||||
emitCron(cronText.trim() || lastCronExpression.current);
|
||||
}
|
||||
},
|
||||
[cronTimeZone, onChange],
|
||||
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
|
||||
);
|
||||
|
||||
const handleIntervalValueChange = useCallback(
|
||||
(text: string) => {
|
||||
// Keep only digits so the cadence stays a positive integer count.
|
||||
const digits = text.replace(/[^0-9]/g, "");
|
||||
setIntervalValueText(digits);
|
||||
const parsed = Number.parseInt(digits, 10);
|
||||
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
|
||||
},
|
||||
[emitInterval, intervalUnit],
|
||||
);
|
||||
|
||||
const handleUnitChange = useCallback(
|
||||
(unit: IntervalUnit) => {
|
||||
setIntervalUnit(unit);
|
||||
emitInterval(parsedIntervalValue, unit);
|
||||
},
|
||||
[emitInterval, parsedIntervalValue],
|
||||
);
|
||||
|
||||
const handleCronChange = useCallback(
|
||||
(text: string) => {
|
||||
setCronText(text);
|
||||
onChange(buildCronCadence(text.trim(), cronTimeZone));
|
||||
emitCron(text.trim());
|
||||
},
|
||||
[cronTimeZone, onChange],
|
||||
[emitCron],
|
||||
);
|
||||
|
||||
let feedback: ReactNode = null;
|
||||
if (effectiveError) {
|
||||
feedback = <Text style={styles.error}>{effectiveError}</Text>;
|
||||
} else if (preview) {
|
||||
feedback = <Text style={styles.preview}>{preview}</Text>;
|
||||
const handlePresetPress = useCallback(
|
||||
(expression: string) => {
|
||||
setCronText(expression);
|
||||
bumpFieldResetKey();
|
||||
emitCron(expression);
|
||||
},
|
||||
[emitCron],
|
||||
);
|
||||
|
||||
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
|
||||
const trimmedCron = cronText.trim();
|
||||
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
|
||||
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
|
||||
|
||||
let cronFeedback: ReactNode = null;
|
||||
if (cronError) {
|
||||
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
|
||||
} else if (cronPreview) {
|
||||
cronFeedback = <Text style={styles.preview}>{cronPreview}</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}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={mode}
|
||||
onValueChange={handleModeChange}
|
||||
options={MODE_OPTIONS}
|
||||
style={styles.modeControl}
|
||||
testID="cadence-mode"
|
||||
/>
|
||||
|
||||
<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>
|
||||
{mode === "every" ? (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.intervalRow}>
|
||||
<AdaptiveTextInput
|
||||
testID="cadence-interval-value"
|
||||
accessibilityLabel="Interval value"
|
||||
initialValue={intervalValueText}
|
||||
resetKey={`cadence-interval-${fieldResetKey}`}
|
||||
value={intervalValueText}
|
||||
onChangeText={handleIntervalValueChange}
|
||||
keyboardType="number-pad"
|
||||
style={styles.intervalInput}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={intervalUnit}
|
||||
onValueChange={handleUnitChange}
|
||||
options={UNIT_OPTIONS}
|
||||
testID="cadence-interval-unit"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.preview}>{intervalPreview}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.presetRow}>
|
||||
{CRON_PRESETS.map((preset) => (
|
||||
<CronPresetChip
|
||||
key={preset.expression}
|
||||
label={preset.label}
|
||||
expression={preset.expression}
|
||||
isSelected={trimmedCron === preset.expression}
|
||||
onSelect={handlePresetPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<AdaptiveTextInput
|
||||
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}
|
||||
/>
|
||||
{cronFeedback}
|
||||
<Text style={styles.hint}>Times are in UTC</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CronPresetChip({
|
||||
label,
|
||||
expression,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
expression: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (expression: string) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(expression);
|
||||
}, [onSelect, expression]);
|
||||
const chipStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.chip,
|
||||
isSelected && styles.chipSelected,
|
||||
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
|
||||
],
|
||||
[isSelected],
|
||||
);
|
||||
const labelStyle = useMemo(
|
||||
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
|
||||
[isSelected],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
style={chipStyle}
|
||||
>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
stack: {
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
intervalRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
intervalInput: {
|
||||
width: 88,
|
||||
minHeight: 44,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
// The mode toggle hugs its options at the left rather than stretching to a
|
||||
// full-width track; the interval row then reads as input + toggle.
|
||||
modeControl: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
presetRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
chip: {
|
||||
minHeight: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chipHover: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
// Selected preset reads as a chosen surface, not a second accent fill
|
||||
// competing with the sheet's primary CTA.
|
||||
chipSelected: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
chipLabelSelected: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
cronInput: {
|
||||
minHeight: 44,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
preview: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hint: {
|
||||
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
@@ -38,13 +38,17 @@ import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
Archive,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
@@ -62,7 +66,9 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
shouldShowSidebarHostLabels,
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -77,6 +83,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
@@ -86,8 +93,6 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
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 {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -111,7 +116,7 @@ import {
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import {
|
||||
getCurrentProjectRemoveReadiness,
|
||||
@@ -137,11 +142,15 @@ const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
@@ -219,9 +228,8 @@ function selectionForSelectedWorkspace(
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
statusGroups: StatusGroup[];
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||
@@ -283,6 +291,16 @@ interface WorkspaceRowInnerProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function getWorkspaceArchiveStatus(
|
||||
isWorktree: boolean,
|
||||
archiveStatus: "idle" | "pending" | "success",
|
||||
isArchivingWorkspace: boolean,
|
||||
): "idle" | "pending" | "success" {
|
||||
if (isWorktree) return archiveStatus;
|
||||
if (isArchivingWorkspace) return "pending";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -340,6 +358,12 @@ function projectKebabStyle({
|
||||
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
||||
}
|
||||
|
||||
function workspaceKebabStyle({
|
||||
hovered = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
selected,
|
||||
@@ -516,6 +540,12 @@ function ProjectRowTrailingActions({
|
||||
|
||||
const trash2LeadingIcon = <ThemedTrash2 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 = (
|
||||
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
@@ -663,7 +693,7 @@ function WorkspaceRowRightGroup({
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
<WorkspaceKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -683,6 +713,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({
|
||||
iconDataUri,
|
||||
placeholderInitial,
|
||||
@@ -1443,7 +1564,20 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1455,6 +1589,7 @@ function WorkspaceRowWithMenu({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -1521,7 +1656,7 @@ function WorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1533,8 +1668,8 @@ function WorkspaceRowWithMenu({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -1559,7 +1694,11 @@ function WorkspaceRowWithMenu({
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(
|
||||
isWorktree,
|
||||
worktreeArchiveStatus,
|
||||
isHidingWorkspace,
|
||||
)}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
@@ -1584,7 +1723,6 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
interface WorkspaceRowItemProps {
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
subtitle?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1600,7 +1738,6 @@ interface WorkspaceRowItemProps {
|
||||
|
||||
function WorkspaceRowItem({
|
||||
workspace,
|
||||
workspaceEntry,
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1623,7 +1760,7 @@ function WorkspaceRowItem({
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspaceEntry={workspaceEntry}
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1661,7 +1798,6 @@ function areWorkspaceRowItemPropsEqual(
|
||||
});
|
||||
return (
|
||||
previous.workspace === next.workspace &&
|
||||
previous.workspaceEntry === next.workspaceEntry &&
|
||||
previous.subtitle === next.subtitle &&
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
@@ -1678,7 +1814,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
|
||||
|
||||
function WorkspaceRow({
|
||||
workspaceEntry,
|
||||
workspace,
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1690,7 +1826,7 @@ function WorkspaceRow({
|
||||
isCreating = false,
|
||||
selected,
|
||||
}: {
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
subtitle?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1702,13 +1838,15 @@ function WorkspaceRow({
|
||||
isCreating?: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (!workspaceEntry) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspaceEntry}
|
||||
workspace={hydratedWorkspace}
|
||||
subtitle={subtitle}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1725,7 +1863,6 @@ function WorkspaceRow({
|
||||
|
||||
function ProjectBlock({
|
||||
project,
|
||||
workspaceEntriesByKey,
|
||||
collapsed,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
@@ -1748,7 +1885,6 @@ function ProjectBlock({
|
||||
supportsMultiplicityByServerId,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
collapsed: boolean;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
@@ -1798,7 +1934,6 @@ function ProjectBlock({
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
subtitle={
|
||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
||||
}
|
||||
@@ -1825,7 +1960,6 @@ function ProjectBlock({
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1980,7 +2114,6 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
||||
previous.collapsed === next.collapsed &&
|
||||
previous.displayName === next.displayName &&
|
||||
previous.iconDataUri === next.iconDataUri &&
|
||||
@@ -2033,9 +2166,8 @@ function areProjectBlockSelectionsEqual(
|
||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||
|
||||
export function SidebarWorkspaceList({
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -2064,7 +2196,7 @@ export function SidebarWorkspaceList({
|
||||
const content =
|
||||
groupMode === "status" ? (
|
||||
<SidebarStatusModeWrapper
|
||||
statusGroups={statusGroups}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -2074,7 +2206,6 @@ export function SidebarWorkspaceList({
|
||||
) : (
|
||||
<ProjectModeList
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
@@ -2093,14 +2224,14 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
|
||||
function SidebarStatusModeWrapper({
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: {
|
||||
statusGroups: StatusGroup[];
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onWorkspacePress?: () => void;
|
||||
@@ -2111,7 +2242,7 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
return (
|
||||
<SidebarStatusWorkspaceList
|
||||
groups={statusGroups}
|
||||
workspaces={statusWorkspacePlacements}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
@@ -2124,7 +2255,6 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
function ProjectModeList({
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2138,7 +2268,7 @@ function ProjectModeList({
|
||||
supportsMultiplicityByServerId,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
> & {
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
@@ -2313,7 +2443,6 @@ function ProjectModeList({
|
||||
return (
|
||||
<MemoProjectBlock
|
||||
project={item}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||
displayName={item.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||
@@ -2352,7 +2481,6 @@ function ProjectModeList({
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
workspaceEntriesByKey,
|
||||
creatingWorkspaceIds,
|
||||
],
|
||||
);
|
||||
@@ -2744,6 +2872,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
borderColor: theme.colors.surface0,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
|
||||
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 {
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
groupMode: SidebarGroupMode;
|
||||
statusGroups: StatusGroup[];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
shortcutModel: SidebarShortcutModel;
|
||||
}
|
||||
|
||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
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 workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || isStatusMode,
|
||||
);
|
||||
const statusGroups = useMemo(
|
||||
() =>
|
||||
isStatusMode
|
||||
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
|
||||
: [],
|
||||
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
|
||||
);
|
||||
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,
|
||||
workspaceEntriesByKey,
|
||||
groupMode,
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
shortcutModel,
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
groupMode,
|
||||
list,
|
||||
shortcutModel,
|
||||
statusGroups,
|
||||
toggleProjectCollapsed,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user