mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215566bcb6 | ||
|
|
bc534ad09c | ||
|
|
d96d599466 | ||
|
|
29a3261dd6 | ||
|
|
c7406a415d | ||
|
|
202c417347 | ||
|
|
3259a69466 | ||
|
|
cab0e4b3f8 | ||
|
|
6ca1f4dac5 | ||
|
|
6f3ab98986 | ||
|
|
251d88a37a | ||
|
|
c72bcb49e5 | ||
|
|
1a03171f0c | ||
|
|
f3fdeab131 | ||
|
|
fdc6a4a57d | ||
|
|
ed67e6e0f1 | ||
|
|
5ec7ef9771 | ||
|
|
4fb70668f2 | ||
|
|
06c3161008 | ||
|
|
a85139c3f3 | ||
|
|
be756c9004 | ||
|
|
8d0b3e69e8 | ||
|
|
d9b65e269f | ||
|
|
d3e1fe48f6 | ||
|
|
783f43c580 | ||
|
|
503a5a9e1e | ||
|
|
f21c997b17 | ||
|
|
7c1da0077b | ||
|
|
d318629121 | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 | ||
|
|
391cf75ece | ||
|
|
7ee2d6fd7e | ||
|
|
b1d11fccd5 | ||
|
|
05f8dda1e3 | ||
|
|
9968f2ba50 | ||
|
|
0ecf2fb8da | ||
|
|
b3eb77981a | ||
|
|
3e28b9b59e | ||
|
|
42d1a2e4e3 | ||
|
|
e98d8c7a36 | ||
|
|
9c220c2451 | ||
|
|
b28f291493 | ||
|
|
f226222638 | ||
|
|
6ce4e3f507 | ||
|
|
8d3a43e05d | ||
|
|
7cb0ccba60 | ||
|
|
f1a8e9a563 | ||
|
|
75b11ac93e | ||
|
|
8680bc2236 | ||
|
|
e67e25628b | ||
|
|
860fe92fdd | ||
|
|
ab62a023fd | ||
|
|
cc21262130 | ||
|
|
2a226b3872 | ||
|
|
a13fa4e614 | ||
|
|
ccd2bb2bf3 | ||
|
|
570e652496 | ||
|
|
6d55565d19 | ||
|
|
fec723c775 | ||
|
|
1bf50349a5 | ||
|
|
cda1ba43cc | ||
|
|
f22865df62 | ||
|
|
33ffeb4d42 | ||
|
|
6781521c8e | ||
|
|
4ca89f21f9 | ||
|
|
9430a897e8 | ||
|
|
b0088303fd | ||
|
|
2ba3ad53ab | ||
|
|
b2714ccd89 | ||
|
|
82993c2197 | ||
|
|
742d47fe71 | ||
|
|
441b935b73 | ||
|
|
b2ad903005 | ||
|
|
554e525186 | ||
|
|
71ce96b60f | ||
|
|
d9615c1e98 | ||
|
|
6fea8305a7 | ||
|
|
172de4fbc2 | ||
|
|
adc0c01782 | ||
|
|
3eed8c31a2 |
@@ -1,11 +1,31 @@
|
|||||||
.git
|
.git
|
||||||
|
.debug.conversations
|
||||||
|
.debug
|
||||||
.dev
|
.dev
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
**/.playwright-mcp
|
||||||
.paseo
|
.paseo
|
||||||
|
**/.paseo-provider-history
|
||||||
|
.plans
|
||||||
.tasks
|
.tasks
|
||||||
|
.valknut
|
||||||
|
.claude/settings.local.json
|
||||||
|
**/.claude/settings.local.json
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
.claude/worktrees
|
||||||
.wrangler
|
.wrangler
|
||||||
**/.wrangler
|
**/.wrangler
|
||||||
**/.tanstack
|
**/.tanstack
|
||||||
|
PLAN.md
|
||||||
|
valknut-report.html
|
||||||
|
valknut-report.json
|
||||||
|
.env*
|
||||||
|
**/.env*
|
||||||
|
.dev.vars
|
||||||
|
**/.dev.vars
|
||||||
|
*.pem
|
||||||
|
**/*.pem
|
||||||
|
**/.secrets
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/build
|
**/build
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
@@ -113,7 +112,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||||
|
|
||||||
@@ -170,7 +168,6 @@ jobs:
|
|||||||
}
|
}
|
||||||
Start-Sleep -Seconds (20 * $attempt)
|
Start-Sleep -Seconds (20 * $attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
@@ -202,7 +199,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
@@ -227,7 +223,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build client dependencies
|
- name: Build client dependencies
|
||||||
run: npm run build:client
|
run: npm run build:client
|
||||||
|
|
||||||
@@ -270,7 +265,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build app dependencies
|
- name: Build app dependencies
|
||||||
run: npm run build:app-deps
|
run: npm run build:app-deps
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,22 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "docker/**"
|
- "docker/**"
|
||||||
|
- ".dockerignore"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
- "patches/**"
|
||||||
|
- "scripts/**"
|
||||||
|
- "tsconfig.json"
|
||||||
|
- "tsconfig.base.json"
|
||||||
|
- "packages/app/**"
|
||||||
|
- "packages/cli/**"
|
||||||
|
- "packages/client/**"
|
||||||
|
- "packages/expo-two-way-audio/**"
|
||||||
|
- "packages/highlight/**"
|
||||||
|
- "packages/protocol/**"
|
||||||
|
- "packages/relay/**"
|
||||||
|
- "packages/server/**"
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags:
|
tags:
|
||||||
@@ -13,7 +28,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
paseo_version:
|
paseo_version:
|
||||||
description: "Paseo version to build. Required when publish is true."
|
description: "Expected source version to build. Required when publish is true."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
publish:
|
publish:
|
||||||
@@ -24,15 +39,6 @@ on:
|
|||||||
options:
|
options:
|
||||||
- "false"
|
- "false"
|
||||||
- "true"
|
- "true"
|
||||||
source_build:
|
|
||||||
description: "Build from the checked-out source tree instead of npm."
|
|
||||||
required: false
|
|
||||||
default: "auto"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- auto
|
|
||||||
- "false"
|
|
||||||
- "true"
|
|
||||||
publish_latest:
|
publish_latest:
|
||||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||||
required: false
|
required: false
|
||||||
@@ -57,7 +63,6 @@ jobs:
|
|||||||
image: ${{ steps.values.outputs.image }}
|
image: ${{ steps.values.outputs.image }}
|
||||||
install_version: ${{ steps.values.outputs.install_version }}
|
install_version: ${{ steps.values.outputs.install_version }}
|
||||||
publish: ${{ steps.values.outputs.publish }}
|
publish: ${{ steps.values.outputs.publish }}
|
||||||
source_build: ${{ steps.values.outputs.source_build }}
|
|
||||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||||
steps:
|
steps:
|
||||||
@@ -68,7 +73,6 @@ jobs:
|
|||||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
|
||||||
REPO_OWNER: ${{ github.repository_owner }}
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -76,9 +80,9 @@ jobs:
|
|||||||
|
|
||||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||||
image="ghcr.io/${owner}/paseo"
|
image="ghcr.io/${owner}/paseo"
|
||||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
package_version="$(node -p "require('./package.json').version")"
|
||||||
|
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
||||||
publish=false
|
publish=false
|
||||||
source_build=false
|
|
||||||
publish_latest=false
|
publish_latest=false
|
||||||
prerelease=false
|
prerelease=false
|
||||||
|
|
||||||
@@ -87,7 +91,6 @@ jobs:
|
|||||||
publish=true
|
publish=true
|
||||||
if [[ "${REF_NAME}" == *-* ]]; then
|
if [[ "${REF_NAME}" == *-* ]]; then
|
||||||
prerelease=true
|
prerelease=true
|
||||||
source_build=true
|
|
||||||
else
|
else
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -104,24 +107,6 @@ jobs:
|
|||||||
prerelease=true
|
prerelease=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
|
||||||
true)
|
|
||||||
source_build=true
|
|
||||||
;;
|
|
||||||
false)
|
|
||||||
source_build=false
|
|
||||||
;;
|
|
||||||
auto)
|
|
||||||
if [[ "${prerelease}" == "true" ]]; then
|
|
||||||
source_build=true
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "::error::source_build must be auto, true, or false."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -137,14 +122,13 @@ jobs:
|
|||||||
echo "image=${image}"
|
echo "image=${image}"
|
||||||
echo "install_version=${install_version}"
|
echo "install_version=${install_version}"
|
||||||
echo "publish=${publish}"
|
echo "publish=${publish}"
|
||||||
echo "source_build=${source_build}"
|
|
||||||
echo "check_tag=${check_tag}"
|
echo "check_tag=${check_tag}"
|
||||||
echo "publish_tags<<EOF"
|
echo "publish_tags<<EOF"
|
||||||
echo "${publish_tags}"
|
echo "${publish_tags}"
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: setup
|
needs: setup
|
||||||
@@ -160,8 +144,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
context: .
|
||||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
file: docker/base/Dockerfile
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
@@ -193,8 +177,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
context: .
|
||||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
file: docker/base/Dockerfile
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ valknut-report.json/
|
|||||||
.plans/
|
.plans/
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||||
|
packages/protocol/src/generated/validation/*.aot.ts
|
||||||
|
|
||||||
/artifacts
|
/artifacts
|
||||||
packages/desktop/.cache/
|
packages/desktop/.cache/
|
||||||
|
|||||||
+192
@@ -84,6 +84,198 @@
|
|||||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "@tanstack/react-query",
|
||||||
|
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
||||||
|
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "react-native-unistyles",
|
||||||
|
"importNames": ["useUnistyles"],
|
||||||
|
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"packages/app/src/data/**/*.{ts,tsx}",
|
||||||
|
"packages/app/src/**/*.test.{ts,tsx}",
|
||||||
|
"packages/app/src/**/*.spec.{ts,tsx}"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "react-native-unistyles",
|
||||||
|
"importNames": ["useUnistyles"],
|
||||||
|
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"packages/app/src/assistant-file-links/use-file-link.ts",
|
||||||
|
"packages/app/src/components/message.tsx",
|
||||||
|
"packages/app/src/components/worktree-setup-callout-source.tsx",
|
||||||
|
"packages/app/src/desktop/hooks/use-daemon-status.ts",
|
||||||
|
"packages/app/src/desktop/hooks/use-install-status.ts",
|
||||||
|
"packages/app/src/desktop/settings/desktop-settings.ts",
|
||||||
|
"packages/app/src/git/pull-request-panel/use-data.ts",
|
||||||
|
"packages/app/src/git/use-github-search-query.ts",
|
||||||
|
"packages/app/src/git/use-pr-status-query.ts",
|
||||||
|
"packages/app/src/git/use-status-query.ts",
|
||||||
|
"packages/app/src/hooks/use-archive-agent.ts",
|
||||||
|
"packages/app/src/hooks/use-agent-autocomplete.ts",
|
||||||
|
"packages/app/src/hooks/use-agent-commands-query.ts",
|
||||||
|
"packages/app/src/hooks/use-agent-history.ts",
|
||||||
|
"packages/app/src/hooks/use-branch-switcher.ts",
|
||||||
|
"packages/app/src/hooks/use-changes-preferences/index.ts",
|
||||||
|
"packages/app/src/hooks/use-draft-agent-features.ts",
|
||||||
|
"packages/app/src/hooks/use-form-preferences.ts",
|
||||||
|
"packages/app/src/hooks/use-is-local-daemon.ts",
|
||||||
|
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
|
||||||
|
"packages/app/src/hooks/use-preferred-editor.ts",
|
||||||
|
"packages/app/src/hooks/use-project-icon-query.ts",
|
||||||
|
"packages/app/src/hooks/use-settings/index.ts",
|
||||||
|
"packages/app/src/panels/terminal-panel.tsx",
|
||||||
|
"packages/app/src/projects/project-icons.ts",
|
||||||
|
"packages/app/src/provider-usage/use-provider-usage.ts",
|
||||||
|
"packages/app/src/screens/project-settings-screen.tsx",
|
||||||
|
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
|
||||||
|
"packages/app/src/workspace/desktop-open-targets.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "react-native-unistyles",
|
||||||
|
"importNames": ["useUnistyles"],
|
||||||
|
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"packages/app/src/app/_layout.tsx",
|
||||||
|
"packages/app/src/app/pair-scan.tsx",
|
||||||
|
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||||
|
"packages/app/src/components/add-host-method-modal.tsx",
|
||||||
|
"packages/app/src/components/add-host-modal.tsx",
|
||||||
|
"packages/app/src/components/agent-list.tsx",
|
||||||
|
"packages/app/src/components/agent-status-dot.tsx",
|
||||||
|
"packages/app/src/components/attachment-lightbox.tsx",
|
||||||
|
"packages/app/src/components/browser-pane.electron.tsx",
|
||||||
|
"packages/app/src/components/browser-pane.tsx",
|
||||||
|
"packages/app/src/components/browser-pane.web.tsx",
|
||||||
|
"packages/app/src/components/command-center.tsx",
|
||||||
|
"packages/app/src/components/context-window-meter.tsx",
|
||||||
|
"packages/app/src/components/dictation-controls.tsx",
|
||||||
|
"packages/app/src/components/download-toast.tsx",
|
||||||
|
"packages/app/src/components/draggable-list.native.tsx",
|
||||||
|
"packages/app/src/components/explorer-sidebar.tsx",
|
||||||
|
"packages/app/src/components/headers/back-header.tsx",
|
||||||
|
"packages/app/src/components/headers/menu-header.tsx",
|
||||||
|
"packages/app/src/components/headers/screen-header.tsx",
|
||||||
|
"packages/app/src/components/host-status-dot.tsx",
|
||||||
|
"packages/app/src/components/hosts/host-picker.tsx",
|
||||||
|
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||||
|
"packages/app/src/components/left-sidebar.tsx",
|
||||||
|
"packages/app/src/components/pair-link-modal.tsx",
|
||||||
|
"packages/app/src/components/plan-card.tsx",
|
||||||
|
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||||
|
"packages/app/src/components/question-form-card.tsx",
|
||||||
|
"packages/app/src/components/quitting-overlay.tsx",
|
||||||
|
"packages/app/src/components/realtime-voice-overlay.tsx",
|
||||||
|
"packages/app/src/components/resize-handle.tsx",
|
||||||
|
"packages/app/src/components/rewind/rewind-menu.tsx",
|
||||||
|
"packages/app/src/components/settings-textarea.tsx",
|
||||||
|
"packages/app/src/components/sidebar-callout.tsx",
|
||||||
|
"packages/app/src/components/split-container.tsx",
|
||||||
|
"packages/app/src/components/split-drop-zone.tsx",
|
||||||
|
"packages/app/src/components/terminal-pane.tsx",
|
||||||
|
"packages/app/src/components/toast-host.tsx",
|
||||||
|
"packages/app/src/components/tool-call-sheet.tsx",
|
||||||
|
"packages/app/src/components/ui/alert.tsx",
|
||||||
|
"packages/app/src/components/ui/autocomplete.tsx",
|
||||||
|
"packages/app/src/components/ui/combobox.tsx",
|
||||||
|
"packages/app/src/components/ui/context-menu.tsx",
|
||||||
|
"packages/app/src/components/ui/dropdown-menu.tsx",
|
||||||
|
"packages/app/src/components/ui/external-link.tsx",
|
||||||
|
"packages/app/src/components/volume-meter.tsx",
|
||||||
|
"packages/app/src/components/web-desktop-scrollbar.tsx",
|
||||||
|
"packages/app/src/components/welcome-screen.tsx",
|
||||||
|
"packages/app/src/composer/agent-controls/index.tsx",
|
||||||
|
"packages/app/src/composer/agent-controls/mode-control.tsx",
|
||||||
|
"packages/app/src/constants/layout.ts",
|
||||||
|
"packages/app/src/desktop/components/desktop-permission-row.tsx",
|
||||||
|
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
|
||||||
|
"packages/app/src/desktop/components/desktop-updates-section.tsx",
|
||||||
|
"packages/app/src/desktop/components/integrations-section.tsx",
|
||||||
|
"packages/app/src/desktop/updates/update-callout-source.tsx",
|
||||||
|
"packages/app/src/git/actions-split-button.tsx",
|
||||||
|
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
|
||||||
|
"packages/app/src/hosts/host-chooser.tsx",
|
||||||
|
"packages/app/src/screens/open-project-screen.tsx",
|
||||||
|
"packages/app/src/screens/projects-screen.tsx",
|
||||||
|
"packages/app/src/screens/sessions-screen.tsx",
|
||||||
|
"packages/app/src/screens/settings-screen.tsx",
|
||||||
|
"packages/app/src/screens/settings/host-page.tsx",
|
||||||
|
"packages/app/src/screens/settings/providers-section.tsx",
|
||||||
|
"packages/app/src/screens/settings/settings-group.tsx",
|
||||||
|
"packages/app/src/screens/startup-splash-screen.tsx",
|
||||||
|
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "@tanstack/react-query",
|
||||||
|
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
||||||
|
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"packages/app/src/components/file-explorer-pane.tsx",
|
||||||
|
"packages/app/src/components/file-pane.tsx",
|
||||||
|
"packages/app/src/components/import-session-sheet.tsx",
|
||||||
|
"packages/app/src/components/project-picker-modal.tsx",
|
||||||
|
"packages/app/src/desktop/components/pair-device-section.tsx",
|
||||||
|
"packages/app/src/screens/new-workspace-screen.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"files": ["**/e2e/fixtures.ts"],
|
"files": ["**/e2e/fixtures.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 0.1.103 - 2026-07-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,34 +21,37 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||||
|
|
||||||
| Doc | What's in it |
|
| Doc | What's in it |
|
||||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
| [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/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
| [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/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/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
| [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/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||||
|
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||||
|
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||||
|
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -91,10 +94,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
|||||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
- New fields: `.optional()` with a sensible default.
|
||||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
|
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
|
||||||
|
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
|
||||||
|
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
|
||||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||||
|
|||||||
+30
-6
@@ -1,9 +1,32 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||||
FROM ${NODE_IMAGE}
|
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||||
|
|
||||||
ARG PASEO_VERSION=latest
|
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}
|
||||||
|
|
||||||
ENV HOME=/home/paseo \
|
ENV HOME=/home/paseo \
|
||||||
PASEO_HOME=/home/paseo/.paseo \
|
PASEO_HOME=/home/paseo/.paseo \
|
||||||
@@ -29,13 +52,14 @@ RUN set -eux; \
|
|||||||
gosu \
|
gosu \
|
||||||
lbzip2 \
|
lbzip2 \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
|
procps \
|
||||||
tini; \
|
tini; \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
npm install -g --omit=optional \
|
npm install -g /tmp/paseo-packs/*.tgz; \
|
||||||
"@getpaseo/server@${PASEO_VERSION}" \
|
rm -rf /tmp/paseo-packs; \
|
||||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
|
||||||
npm cache clean --force; \
|
npm cache clean --force; \
|
||||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||||
test -f "$server_entry"; \
|
test -f "$server_entry"; \
|
||||||
@@ -66,7 +90,7 @@ RUN set -eux; \
|
|||||||
"$XDG_CACHE_HOME"; \
|
"$XDG_CACHE_HOME"; \
|
||||||
chown -R paseo:paseo /home/paseo /workspace
|
chown -R paseo:paseo /home/paseo /workspace
|
||||||
|
|
||||||
COPY rootfs/ /
|
COPY docker/base/rootfs/ /
|
||||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
# 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.
|
> **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 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.
|
> **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.
|
||||||
|
|
||||||
### `packages/website` — Marketing site
|
### `packages/website` — Marketing site
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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.
|
||||||
@@ -4,6 +4,10 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
|
|||||||
|
|
||||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
|
|
||||||
|
## Store Surface Rules
|
||||||
|
|
||||||
|
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|||||||
@@ -173,11 +173,33 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
|||||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||||
|
|
||||||
|
### Agent Tool Catalog Measurement
|
||||||
|
|
||||||
|
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
|
## paseo.json service scripts
|
||||||
|
|
||||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||||
of commands. Both run sequentially.
|
of commands. Both run sequentially.
|
||||||
|
|
||||||
|
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||||
|
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||||
|
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||||
|
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||||
|
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||||
|
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||||
|
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||||
|
they launch in a terminal and receive the service environment described below.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"worktree": {
|
"worktree": {
|
||||||
@@ -281,6 +303,8 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
|
|||||||
|
|
||||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||||
|
|
||||||
|
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
|
||||||
|
|
||||||
For tighter loops, you can rebuild a single workspace:
|
For tighter loops, you can rebuild a single workspace:
|
||||||
|
|
||||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||||
|
|||||||
+9
-10
@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
|||||||
|
|
||||||
The official image:
|
The official image:
|
||||||
|
|
||||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
||||||
or from source-built workspace tarballs for beta images
|
|
||||||
- runs the daemon as the non-root `paseo` user
|
- runs the daemon as the non-root `paseo` user
|
||||||
- listens on `0.0.0.0:6767` inside the container
|
- listens on `0.0.0.0:6767` inside the container
|
||||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||||
@@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
|||||||
## Building Locally
|
## Building Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t paseo:local docker/base
|
docker build -f docker/base/Dockerfile -t paseo:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
To bake a specific published npm version:
|
To assert the source tree version while building:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg PASEO_VERSION=0.1.102 \
|
--build-arg PASEO_VERSION=0.1.102 \
|
||||||
-t paseo:0.1.102 \
|
-t paseo:0.1.102 \
|
||||||
docker/base
|
-f docker/base/Dockerfile \
|
||||||
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker workflow builds the image on pull requests and on `main` as a
|
The Docker workflow builds the image on pull requests and on `main` as a
|
||||||
@@ -216,13 +216,12 @@ pushing a `v*` release tag:
|
|||||||
gh workflow run docker.yml \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=0.1.102-beta.1 \
|
-f paseo_version=0.1.102-beta.1 \
|
||||||
-f publish=true \
|
-f publish=true
|
||||||
-f source_build=auto
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
||||||
versions build from the checked-out source tree by default and publish only the
|
from the checked-out source tree and publishes only the exact prerelease image
|
||||||
exact prerelease image tag.
|
tag for prerelease versions.
|
||||||
|
|
||||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,29 @@ input focused while its scrollable list lives in a Portal. There is no shared
|
|||||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||||
until then prefer copying the closest file and trimming.
|
until then prefer copying the closest file and trimming.
|
||||||
|
|
||||||
|
## Popover width contract
|
||||||
|
|
||||||
|
Combobox desktop popovers are never narrower than their trigger, and they grow
|
||||||
|
with content up to a ceiling that is never below the trigger:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
||||||
|
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
|
||||||
|
```
|
||||||
|
|
||||||
|
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
|
||||||
|
trigger still wins when it is wider. Changing this default requires re-verifying
|
||||||
|
every consumer listed here.
|
||||||
|
|
||||||
|
Consumers: `composer/agent-controls/mode-control.tsx`,
|
||||||
|
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
|
||||||
|
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
|
||||||
|
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
|
||||||
|
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
|
||||||
|
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
|
||||||
|
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
|
||||||
|
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
|
||||||
|
|
||||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||||
|
|
||||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 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).
|
||||||
@@ -198,6 +198,19 @@ for i in $(seq 1 "$ITERATIONS"); do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Android audio focus interruptions
|
||||||
|
|
||||||
|
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb shell am start -n sh.paseo/.MainActivity
|
||||||
|
# Start voice mode in an existing composer, then background Paseo with Home.
|
||||||
|
adb emu gsm call 5551234
|
||||||
|
# Foreground Paseo while the call is still ringing.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||||
|
|
||||||
## Unistyles + Reanimated
|
## Unistyles + Reanimated
|
||||||
|
|
||||||
### The crash
|
### The crash
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ Anyone who builds software:
|
|||||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||||
- Voice mode: dictate prompts or talk through problems hands-free
|
- Voice mode: dictate prompts or talk through problems hands-free
|
||||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
|
||||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||||
- Frequent releases (multiple per week)
|
- Frequent releases (multiple per week)
|
||||||
- Community contributions across packaging, providers, and bug fixes
|
- Community contributions across packaging, providers, and bug fixes
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 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,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
|||||||
|
|
||||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||||
|
|
||||||
|
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||||
|
|
||||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||||
|
|
||||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||||
|
|||||||
+2
-3
@@ -50,7 +50,7 @@ npm run release:patch
|
|||||||
|
|
||||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||||
|
|
||||||
The Docker workflow builds images 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.
|
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`.
|
||||||
|
|
||||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||||
|
|
||||||
@@ -281,8 +281,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
|||||||
gh workflow run docker.yml \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=X.Y.Z-beta.N \
|
-f paseo_version=X.Y.Z-beta.N \
|
||||||
-f publish=true \
|
-f publish=true
|
||||||
-f source_build=auto
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
sha256-RtZABQl17BlUyvFiFw9W5g2E/dpNlrOWpuCdV8wZk5o=
|
||||||
|
|||||||
Generated
+49
-28
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -21797,9 +21797,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.6",
|
"version": "4.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -28563,9 +28563,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -35069,6 +35069,26 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod-aot": {
|
||||||
|
"version": "0.20.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
|
||||||
|
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.16.0",
|
||||||
|
"get-tsconfig": "^4.14.0",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"unplugin": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"zod-aot": "dist/cli/index.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.25.1",
|
"version": "3.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||||
@@ -35132,7 +35152,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -36150,12 +36170,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.103",
|
"@getpaseo/client": "0.1.104",
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104",
|
||||||
"@getpaseo/server": "0.1.103",
|
"@getpaseo/server": "0.1.104",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36401,10 +36421,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104",
|
||||||
"@getpaseo/relay": "0.1.103",
|
"@getpaseo/relay": "0.1.104",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36415,7 +36435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36658,7 +36678,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37554,7 +37574,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37786,19 +37806,20 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6",
|
||||||
|
"zod-aot": "0.20.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38016,15 +38037,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.103",
|
"@getpaseo/client": "0.1.104",
|
||||||
"@getpaseo/highlight": "0.1.103",
|
"@getpaseo/highlight": "0.1.104",
|
||||||
"@getpaseo/protocol": "0.1.103",
|
"@getpaseo/protocol": "0.1.104",
|
||||||
"@getpaseo/relay": "0.1.103",
|
"@getpaseo/relay": "0.1.104",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38561,7 +38582,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||||
|
|||||||
@@ -218,6 +218,38 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await expectFlatFileList(page);
|
||||||
|
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-options-menu").click();
|
||||||
|
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
||||||
|
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||||
|
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
|
||||||
|
await page.getByTestId("changes-toggle-whitespace").click();
|
||||||
|
await page.getByTestId("changes-options-menu").click();
|
||||||
|
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await scrollToLowerUnwrappedDiffRows(page);
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("diff-folder-src-toggle").click();
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await expectFlatFileList(page);
|
||||||
|
});
|
||||||
|
|
||||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -266,13 +298,24 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
|||||||
({ preferencesKey }) => {
|
({ preferencesKey }) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
preferencesKey,
|
preferencesKey,
|
||||||
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
JSON.stringify({
|
||||||
|
layout: "unified",
|
||||||
|
viewMode: "flat",
|
||||||
|
wrapLines: false,
|
||||||
|
hideWhitespace: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectFlatFileList(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
||||||
|
}
|
||||||
|
|
||||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
|
|||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
|
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||||
|
|
||||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||||
@@ -57,7 +59,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
|||||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce: nonce }) => {
|
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
@@ -73,12 +75,20 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
|||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||||
|
|
||||||
|
const rawExtraHosts = localStorage.getItem(extraHostsKey);
|
||||||
|
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||||
localStorage.removeItem("@paseo:settings");
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
},
|
},
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
{
|
||||||
|
daemon: testDaemon,
|
||||||
|
preferences: createAgentPreferences,
|
||||||
|
seedNonce,
|
||||||
|
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await provide();
|
await provide();
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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([]);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import { type SeededWorkspace } from "./seed-client";
|
|||||||
|
|
||||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||||
const FAKE_HOST_MODEL_ID = "fake-host-model";
|
export const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||||
const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||||
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
type WebSocketMessage = string | Buffer;
|
||||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||||
@@ -20,6 +20,31 @@ export interface FakeScheduleHostWorkspace {
|
|||||||
workspace: Record<string, unknown>;
|
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 {
|
function parseJson(message: WebSocketMessage): unknown {
|
||||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +139,7 @@ export async function installFakeScheduleHost(input: {
|
|||||||
port: string;
|
port: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: Record<string, unknown>;
|
workspace: Record<string, unknown>;
|
||||||
|
schedules?: FakeScheduleSummary[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
@@ -201,7 +227,7 @@ export async function installFakeScheduleHost(input: {
|
|||||||
ws.send(
|
ws.send(
|
||||||
buildSessionMessage("schedule/list/response", {
|
buildSessionMessage("schedule/list/response", {
|
||||||
requestId,
|
requestId,
|
||||||
schedules: [],
|
schedules: input.schedules ?? [],
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -232,15 +258,24 @@ export async function addFakeScheduleHostAndReload(input: {
|
|||||||
}
|
}
|
||||||
const raw = localStorage.getItem(keys.registry);
|
const raw = localStorage.getItem(keys.registry);
|
||||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
||||||
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
|
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
|
||||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
nextRegistry.push(seededHost);
|
||||||
|
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
|
||||||
|
|
||||||
|
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
|
||||||
|
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
|
||||||
|
? JSON.parse(rawExtraHosts)
|
||||||
|
: [];
|
||||||
|
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
|
||||||
|
nextExtraHosts.push(seededHost);
|
||||||
|
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
seededHost: host,
|
seededHost: host,
|
||||||
keys: {
|
keys: {
|
||||||
registry: REGISTRY_KEY,
|
registry: REGISTRY_KEY,
|
||||||
nonce: SEED_NONCE_KEY,
|
nonce: SEED_NONCE_KEY,
|
||||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
extraHosts: EXTRA_HOSTS_KEY,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
|||||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
// Host group rows are now flat top-level sections (no drill-in).
|
// Host group rows are now flat top-level sections (no drill-in).
|
||||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { expect, type Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
interface SettledOptions {
|
||||||
|
timeout?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
intervalMs?: number;
|
||||||
|
heightTolerance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sample {
|
||||||
|
text: string;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DURATION_MS = 1_500;
|
||||||
|
const DEFAULT_INTERVAL_MS = 100;
|
||||||
|
const DEFAULT_HEIGHT_TOLERANCE = 1;
|
||||||
|
|
||||||
|
async function sample(locator: Locator): Promise<Sample> {
|
||||||
|
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
|
||||||
|
if (!box) {
|
||||||
|
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
|
||||||
|
}
|
||||||
|
return { text: text ?? "", height: box.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
|
||||||
|
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||||
|
|
||||||
|
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||||
|
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||||
|
const samples: Sample[] = [];
|
||||||
|
const deadline = Date.now() + durationMs;
|
||||||
|
|
||||||
|
while (Date.now() <= deadline || samples.length < 2) {
|
||||||
|
samples.push(await sample(locator));
|
||||||
|
await locator.page().waitForTimeout(intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStableHeight(samples: Sample[], tolerance: number): void {
|
||||||
|
const heights = samples.map((entry) => entry.height);
|
||||||
|
const minHeight = Math.min(...heights);
|
||||||
|
const maxHeight = Math.max(...heights);
|
||||||
|
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
|
||||||
|
tolerance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSettledText(samples: Sample[]): void {
|
||||||
|
let stableText: string | null = null;
|
||||||
|
let previousText: string | null = null;
|
||||||
|
|
||||||
|
for (const entry of samples) {
|
||||||
|
if (stableText !== null && entry.text !== stableText) {
|
||||||
|
throw new Error(
|
||||||
|
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousText === entry.text) {
|
||||||
|
stableText = entry.text;
|
||||||
|
}
|
||||||
|
previousText = entry.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stableText === null) {
|
||||||
|
throw new Error("Text did not reach a stable value during the settledness window.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
|
||||||
|
const samples = await collectSamples(locator, options);
|
||||||
|
assertSettledText(samples);
|
||||||
|
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectStableHeight(
|
||||||
|
locator: Locator,
|
||||||
|
options?: SettledOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const samples = await collectSamples(locator, options);
|
||||||
|
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
selectNewWorkspaceProject,
|
selectNewWorkspaceProject,
|
||||||
submitNewWorkspacePrompt,
|
submitNewWorkspacePrompt,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
|
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||||
import { escapeRegex } from "./helpers/regex";
|
import { escapeRegex } from "./helpers/regex";
|
||||||
import { seedWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
@@ -91,6 +92,10 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||||
await modeControl.click();
|
await modeControl.click();
|
||||||
|
|
||||||
|
const popup = page.getByTestId("combobox-desktop-container").last();
|
||||||
|
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectNoTruncation(popup);
|
||||||
|
|
||||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill(label);
|
await searchInput.fill(label);
|
||||||
@@ -105,6 +110,19 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectThinkingOptionsFit(page: Page): Promise<void> {
|
||||||
|
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
|
||||||
|
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
|
||||||
|
await thinkingControl.click();
|
||||||
|
|
||||||
|
const popup = page.getByTestId("combobox-desktop-container").last();
|
||||||
|
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectNoTruncation(popup);
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||||
}> {
|
}> {
|
||||||
@@ -158,6 +176,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
|||||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
await expectThinkingOptionsFit(page);
|
||||||
await selectMode(page, "Full access");
|
await selectMode(page, "Full access");
|
||||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
openStartingRefPicker,
|
openStartingRefPicker,
|
||||||
selectBranchInPicker,
|
selectBranchInPicker,
|
||||||
selectWorkspaceIsolation,
|
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
|
import { expectNoTruncation } from "./helpers/no-truncation";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
@@ -63,7 +63,11 @@ test.describe("New workspace isolation memory", () => {
|
|||||||
projectDisplayName: openedProject.projectDisplayName,
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectWorkspaceIsolationSelected(page, "local");
|
await expectWorkspaceIsolationSelected(page, "local");
|
||||||
await selectWorkspaceIsolation(page, "worktree");
|
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 expectWorkspaceIsolationSelected(page, "worktree");
|
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||||
|
|
||||||
await openStartingRefPicker(page);
|
await openStartingRefPicker(page);
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { expect, test } from "./fixtures";
|
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 { 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";
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||||
|
|
||||||
interface ScheduleSeedClient {
|
interface ScheduleSeedClient {
|
||||||
@@ -56,6 +66,42 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
|
|||||||
.catch(ignoreScheduleDeleteError);
|
.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", () => {
|
test.describe("Schedules", () => {
|
||||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
@@ -79,14 +125,114 @@ test.describe("Schedules", () => {
|
|||||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||||
|
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
|
await expectStableHeight(formSheet);
|
||||||
workspace.projectDisplayName,
|
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||||
{ timeout: 30_000 },
|
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||||
);
|
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||||
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
|
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||||
timeout: 30_000,
|
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||||
|
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(hostTrigger).toBeDisabled();
|
||||||
|
await expectSettled(hostTrigger);
|
||||||
|
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||||
|
await expectSettled(projectTrigger);
|
||||||
|
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||||
|
await expectSettled(modelTrigger);
|
||||||
|
await expect(thinkingTrigger).toHaveCount(0);
|
||||||
|
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectSettled(modeTrigger);
|
||||||
|
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||||
|
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||||
|
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
|
||||||
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
|
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||||
|
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||||
|
const scheduleId = "fake-host-schedule";
|
||||||
|
const scheduleName = "Secondary host schedule";
|
||||||
|
|
||||||
|
await installFakeScheduleHost({
|
||||||
|
page,
|
||||||
|
port: fakePort,
|
||||||
|
serverId: fakeHost.serverId,
|
||||||
|
workspace: fakeHost.workspace,
|
||||||
|
schedules: [
|
||||||
|
buildFakeHostSchedule({
|
||||||
|
id: scheduleId,
|
||||||
|
name: scheduleName,
|
||||||
|
cwd: String(fakeHost.workspace.workspaceDirectory),
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await page.goto(buildSchedulesRoute());
|
||||||
|
await addFakeScheduleHostAndReload({
|
||||||
|
page,
|
||||||
|
serverId: fakeHost.serverId,
|
||||||
|
label: "Fake host",
|
||||||
|
port: fakePort,
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
|
||||||
|
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||||
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectStableHeight(formSheet);
|
||||||
|
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||||
|
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||||
|
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||||
|
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||||
|
|
||||||
|
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
|
||||||
|
await expect(hostTrigger).toBeDisabled();
|
||||||
|
await expectSettled(hostTrigger);
|
||||||
|
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
|
||||||
|
await expectSettled(projectTrigger);
|
||||||
|
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
|
||||||
|
await expectSettled(modelTrigger);
|
||||||
|
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
|
||||||
|
await expectSettled(modeTrigger);
|
||||||
|
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create opens pristine after closing an edit form", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
||||||
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
|
const scheduleName = `Pristine create ${Date.now()}`;
|
||||||
|
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||||
|
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||||
|
|
||||||
|
await page.goto(buildSchedulesRoute());
|
||||||
|
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||||
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectStableHeight(formSheet);
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByTestId("schedules-new").click();
|
||||||
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
|
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||||
|
await expect(projectTrigger).toContainText(/select project/i);
|
||||||
|
await expectSettled(projectTrigger);
|
||||||
|
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,27 @@ import {
|
|||||||
installFakeScheduleHost,
|
installFakeScheduleHost,
|
||||||
} from "./helpers/schedule-fake-host";
|
} from "./helpers/schedule-fake-host";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
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 { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||||
|
|
||||||
|
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
|
||||||
|
|
||||||
interface ScheduleListItem {
|
interface ScheduleListItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
target: { type: string; config?: { cwd?: string } };
|
cadence?: { type: "cron"; expression: string };
|
||||||
|
target: {
|
||||||
|
type: string;
|
||||||
|
config?: {
|
||||||
|
cwd?: string;
|
||||||
|
archiveOnFinish?: boolean;
|
||||||
|
isolation?: "local" | "worktree";
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleSeedClient {
|
interface ScheduleSeedClient {
|
||||||
@@ -24,7 +38,12 @@ async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
|||||||
await page.getByRole("button", { name: /select model/i }).click();
|
await page.getByRole("button", { name: /select model/i }).click();
|
||||||
const popup = page.getByTestId("combobox-desktop-container");
|
const popup = page.getByTestId("combobox-desktop-container");
|
||||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
await expect(popup).toBeVisible({ timeout: 30_000 });
|
||||||
await popup.getByText(label, { exact: true }).click();
|
const searchInput = page.getByTestId("model-search-input").first();
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||||
|
await searchInput.fill(label);
|
||||||
|
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
||||||
|
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||||
|
await option.click();
|
||||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +59,33 @@ async function deleteScheduleByName(workspace: SeededWorkspace, name: string): P
|
|||||||
async function expectScheduleCreatedForProject(input: {
|
async function expectScheduleCreatedForProject(input: {
|
||||||
workspace: SeededWorkspace;
|
workspace: SeededWorkspace;
|
||||||
name: string;
|
name: string;
|
||||||
|
cadenceExpression: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||||
|
const list = await client.scheduleList();
|
||||||
|
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
||||||
|
expect(schedule).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: input.name,
|
||||||
|
cadence: expect.objectContaining({
|
||||||
|
type: "cron",
|
||||||
|
expression: input.cadenceExpression,
|
||||||
|
}),
|
||||||
|
target: expect.objectContaining({
|
||||||
|
type: "new-agent",
|
||||||
|
config: expect.objectContaining({
|
||||||
|
cwd: input.workspace.repoPath,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectScheduleKnobs(input: {
|
||||||
|
workspace: SeededWorkspace;
|
||||||
|
name: string;
|
||||||
|
archiveOnFinish: boolean;
|
||||||
|
isolation: "local" | "worktree";
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||||
const list = await client.scheduleList();
|
const list = await client.scheduleList();
|
||||||
@@ -50,13 +96,54 @@ async function expectScheduleCreatedForProject(input: {
|
|||||||
target: expect.objectContaining({
|
target: expect.objectContaining({
|
||||||
type: "new-agent",
|
type: "new-agent",
|
||||||
config: expect.objectContaining({
|
config: expect.objectContaining({
|
||||||
cwd: input.workspace.repoPath,
|
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", () => {
|
test.describe("Schedules project target", () => {
|
||||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
@@ -67,10 +154,30 @@ test.describe("Schedules project target", () => {
|
|||||||
cleanupTasks.length = 0;
|
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 ({
|
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
const scheduleName = `Project schedule ${Date.now()}`;
|
const scheduleName = `Project schedule ${Date.now()}`;
|
||||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||||
@@ -84,29 +191,84 @@ test.describe("Schedules project target", () => {
|
|||||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectStableHeight(formSheet);
|
||||||
|
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||||
|
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||||
workspace.projectDisplayName,
|
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||||
|
await expectSettled(projectTrigger);
|
||||||
|
|
||||||
|
await selectModelByLabel(page, "Ten second stream");
|
||||||
|
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||||
|
await expect(modelTrigger).toContainText("Ten second stream");
|
||||||
|
await expectSettled(modelTrigger);
|
||||||
|
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
||||||
|
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
||||||
|
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
||||||
|
await page.getByTestId("schedule-cadence-preset-daily-9").click();
|
||||||
|
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||||
|
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
|
||||||
|
|
||||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
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 page.getByRole("button", { name: "Create schedule" }).click();
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
await expectScheduleCreatedForProject({
|
||||||
|
workspace,
|
||||||
|
name: scheduleName,
|
||||||
|
cadenceExpression: "0 9 * * *",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||||
|
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||||
|
await installFakeScheduleHost({
|
||||||
|
page,
|
||||||
|
port: fakePort,
|
||||||
|
serverId: fakeHost.serverId,
|
||||||
|
workspace: fakeHost.workspace,
|
||||||
|
});
|
||||||
|
await addFakeScheduleHostAndReload({
|
||||||
|
page,
|
||||||
|
serverId: fakeHost.serverId,
|
||||||
|
label: "Fake host",
|
||||||
|
port: fakePort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
|
||||||
|
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
await hostFilterTrigger.click();
|
||||||
|
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
|
||||||
|
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectNoTruncation(hostFilterPopup);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears the selected model when the chosen project moves to another host", async ({
|
test("clears the selected project and model when the host changes", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
|
const serverId = getServerId();
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||||
|
|
||||||
@@ -128,26 +290,126 @@ test.describe("Schedules project target", () => {
|
|||||||
});
|
});
|
||||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||||
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectStableHeight(formSheet);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||||
|
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||||
|
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||||
|
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||||
|
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||||
|
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(projectTrigger).toHaveCount(0);
|
||||||
|
await expect(modelTrigger).toHaveCount(0);
|
||||||
|
await expect(thinkingTrigger).toHaveCount(0);
|
||||||
|
await expect(modeTrigger).toHaveCount(0);
|
||||||
|
await hostTrigger.click();
|
||||||
|
await page.getByTestId(`schedule-host-option-${serverId}`).click();
|
||||||
|
await expect(projectTrigger).toBeVisible();
|
||||||
|
await expect(modelTrigger).toHaveCount(0);
|
||||||
|
await expect(thinkingTrigger).toHaveCount(0);
|
||||||
|
await expect(modeTrigger).toHaveCount(0);
|
||||||
|
await expectSettled(hostTrigger);
|
||||||
|
|
||||||
|
await projectTrigger.click();
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||||
workspace.projectDisplayName,
|
await expectSettled(projectTrigger);
|
||||||
);
|
|
||||||
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
await selectModelByLabel(page, "Ten second stream");
|
||||||
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
await expect(modelTrigger).toContainText("Ten second stream");
|
||||||
|
await expectSettled(modelTrigger);
|
||||||
|
await expect(thinkingTrigger).toHaveCount(0);
|
||||||
|
await expect(modeTrigger).toBeVisible();
|
||||||
|
await expectSettled(modeTrigger);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await hostTrigger.click();
|
||||||
|
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
|
||||||
|
await expect(hostTrigger).toContainText("Fake host");
|
||||||
|
await expect(projectTrigger).toContainText(/select project/i);
|
||||||
|
await expect(modelTrigger).toHaveCount(0);
|
||||||
|
await expect(thinkingTrigger).toHaveCount(0);
|
||||||
|
await expect(modeTrigger).toHaveCount(0);
|
||||||
|
await expectSettled(hostTrigger);
|
||||||
|
await expectSettled(projectTrigger);
|
||||||
|
|
||||||
|
await projectTrigger.click();
|
||||||
|
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
||||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||||
fakeHost.projectDisplayName,
|
await expectSettled(projectTrigger);
|
||||||
);
|
await expect(modelTrigger).toContainText(/select model/i);
|
||||||
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
await expectSettled(modelTrigger);
|
||||||
|
await expect(thinkingTrigger).toHaveCount(0);
|
||||||
|
await expect(modeTrigger).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
|
||||||
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
|
const scheduleName = `Knob schedule ${Date.now()}`;
|
||||||
|
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await page.goto(buildSchedulesRoute());
|
||||||
|
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||||
|
await selectModelByLabel(page, "Ten second stream");
|
||||||
|
await expect(
|
||||||
|
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page.getByTestId("schedule-isolation-trigger").click();
|
||||||
|
await page.getByTestId("schedule-isolation-worktree").click();
|
||||||
|
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
||||||
|
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
||||||
|
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||||
|
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
|
||||||
|
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
||||||
|
await page.getByTestId("schedule-cadence-preset-every-hour").click();
|
||||||
|
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expectScheduleKnobs({
|
||||||
|
workspace,
|
||||||
|
name: scheduleName,
|
||||||
|
archiveOnFinish: false,
|
||||||
|
isolation: "worktree",
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
|
||||||
|
await page.getByTestId(`schedule-row-${scheduleId}`).click();
|
||||||
|
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||||
|
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expectStableHeight(formSheet);
|
||||||
|
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
||||||
|
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("schedule-isolation-trigger").click();
|
||||||
|
await page.getByTestId("schedule-isolation-local").click();
|
||||||
|
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
||||||
|
await page.getByRole("button", { name: "Save changes" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expectScheduleKnobs({
|
||||||
|
workspace,
|
||||||
|
name: scheduleName,
|
||||||
|
archiveOnFinish: true,
|
||||||
|
isolation: "local",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
|
|||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await openHostSection(page, serverId, "host");
|
await openHostSection(page, serverId, "host");
|
||||||
await expectSettingsHeader(page, "Host");
|
await expectSettingsHeader(page, "Overview");
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
|||||||
|
|
||||||
const config = getDefaultConfig(projectRoot);
|
const config = getDefaultConfig(projectRoot);
|
||||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||||
|
|
||||||
|
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
|
||||||
|
// crawler behavior depends on the host Watchman build/capabilities, while the
|
||||||
|
// node crawler is the path used when Watchman is absent.
|
||||||
|
config.resolver.useWatchman = false;
|
||||||
|
|
||||||
const escapedAppSrcRoot = appSrcRoot
|
const escapedAppSrcRoot = appSrcRoot
|
||||||
.split(path.sep)
|
.split(path.sep)
|
||||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.103",
|
"version": "0.1.104",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||||
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||||
@@ -318,6 +319,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
@@ -633,10 +635,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
args={item.text}
|
args={item.text}
|
||||||
status={item.status === "ready" ? "completed" : "executing"}
|
status={item.status === "ready" ? "completed" : "executing"}
|
||||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||||
|
defaultExpanded={autoExpandReasoning}
|
||||||
|
forceInline={autoExpandReasoning}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[setInlineDetailsExpanded],
|
[autoExpandReasoning, setInlineDetailsExpanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderToolCallItem = useCallback(
|
const renderToolCallItem = useCallback(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { LeftSidebar } from "@/components/left-sidebar";
|
|||||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||||
|
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||||
@@ -84,7 +85,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/data/query-client";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
hasConfiguredLocalDaemonOverride,
|
||||||
@@ -998,31 +999,27 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PortalProvider must stay inside normal app-wide context providers here.
|
// PortalProvider must stay inside normal app-wide context providers.
|
||||||
// `@gorhom/portal` renders portaled children at the host's location in the
|
// `@gorhom/portal` renders portaled children at the host's location in the
|
||||||
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
||||||
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
|
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
|
||||||
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
||||||
// context and need one shared provider for sibling sheets to stack.
|
// context and need one shared provider for sibling sheets to stack.
|
||||||
function RootProviders({ children }: { children: ReactNode }) {
|
function RootProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<SafeAreaProvider>
|
||||||
<I18nProvider>
|
<KeyboardProvider>
|
||||||
<SafeAreaProvider>
|
<KeyboardShiftProvider>
|
||||||
<KeyboardProvider>
|
<PortalProvider>
|
||||||
<KeyboardShiftProvider>
|
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||||
<PortalProvider>
|
</PortalProvider>
|
||||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
</KeyboardShiftProvider>
|
||||||
</PortalProvider>
|
</KeyboardProvider>
|
||||||
</KeyboardShiftProvider>
|
</SafeAreaProvider>
|
||||||
</KeyboardProvider>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</QueryProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
function RootAppTree() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={flexStyle}>
|
<GestureHandlerRootView style={flexStyle}>
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
@@ -1036,6 +1033,18 @@ export default function RootLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<RootErrorBoundary>
|
||||||
|
<RootAppTree />
|
||||||
|
</RootErrorBoundary>
|
||||||
|
</I18nProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const layoutStyles = StyleSheet.create((theme) => ({
|
const layoutStyles = StyleSheet.create((theme) => ({
|
||||||
surfaceFill: {
|
surfaceFill: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
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()} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsSectionRoute() {
|
export default function SettingsSectionRoute() {
|
||||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||||
@@ -10,5 +41,14 @@ export default function SettingsSectionRoute() {
|
|||||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||||
|
|
||||||
|
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||||
|
if (rawSection === "daemon") {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<SettingsDaemonRedirect />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
@@ -36,13 +36,6 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
|
|||||||
return "Review";
|
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(
|
function getTextAttachmentSubtitle(
|
||||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -96,11 +89,10 @@ export function getWorkspaceAttachmentPillContent(
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
): AttachmentPillContent {
|
): AttachmentPillContent {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "browser_element") {
|
||||||
const intent = attachment.attachment.intent;
|
|
||||||
return {
|
return {
|
||||||
icon: attachmentBrowserIcon,
|
icon: attachmentBrowserIcon,
|
||||||
title: attachment.attachment.tag,
|
title: attachment.attachment.tag,
|
||||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
subtitle: t("composer.attachments.element"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ export interface AttachmentMetadata {
|
|||||||
createdAt: number;
|
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 {
|
export interface BrowserElementAttachment {
|
||||||
url: string;
|
url: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
@@ -50,8 +44,6 @@ export interface BrowserElementAttachment {
|
|||||||
children: string[];
|
children: string[];
|
||||||
/** Free-text review note the user wrote about this element, if any. */
|
/** Free-text review note the user wrote about this element, if any. */
|
||||||
comment?: string;
|
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
|
* Cropped screenshot of the selected element, sent to the agent as an image
|
||||||
* alongside the textual element context. Persisted via the attachment store;
|
* alongside the textual element context. Persisted via the attachment store;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||||
import { mountBrowserAutomationHandler } from "./handler";
|
import { mountBrowserAutomationHandler } from "./handler";
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
import type { DesktopHostBridge } from "@/desktop/host";
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
import { useBrowserStore } from "@/stores/browser-store";
|
||||||
@@ -8,20 +9,6 @@ import {
|
|||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
} from "@/stores/workspace-layout-store";
|
} 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<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "browser.automation.execute.request" }
|
{ type: "browser.automation.execute.request" }
|
||||||
@@ -30,9 +17,28 @@ type BrowserAutomationExecuteResponse = Extract<
|
|||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
{ type: "browser.automation.execute.response" }
|
{ 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 {
|
class FakeDaemonClient {
|
||||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||||
|
|
||||||
public on(
|
public on(
|
||||||
@@ -55,6 +61,107 @@ class FakeDaemonClient {
|
|||||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||||
this.handler?.(nextRequest);
|
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 {
|
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||||
@@ -70,45 +177,69 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
|||||||
type: "browser.automation.execute.request",
|
type: "browser.automation.execute.request",
|
||||||
requestId: "req-new",
|
requestId: "req-new",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
workspaceId: "/repo",
|
workspaceId: "wks_workspace_a",
|
||||||
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
|
command: {
|
||||||
|
command: "new_tab",
|
||||||
|
args: { url: "https://example.com" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
function browserResizeRequest(
|
||||||
|
browserId: string,
|
||||||
|
input: { workspaceId?: string } = {},
|
||||||
|
): BrowserAutomationExecuteRequest {
|
||||||
|
return {
|
||||||
|
type: "browser.automation.execute.request",
|
||||||
|
requestId: "req-resize",
|
||||||
|
agentId: "agent-1",
|
||||||
|
workspaceId: input.workspaceId ?? "wks_workspace_a",
|
||||||
|
command: {
|
||||||
|
command: "resize",
|
||||||
|
args: { browserId, width: 1024, height: 768 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
|
||||||
|
return {
|
||||||
|
type: "browser.automation.execute.request",
|
||||||
|
requestId: "req-close-tab",
|
||||||
|
agentId: "agent-1",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
command: {
|
||||||
|
command: "close_tab",
|
||||||
|
args: { browserId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
command: "list_tabs" as const,
|
command: "list_tabs",
|
||||||
tabs: [],
|
tabs: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
command: "list_tabs" as const,
|
command: "list_tabs",
|
||||||
tabs: currentBrowserTabs(),
|
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() {
|
function currentBrowserTabs() {
|
||||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||||
browserId: browser.browserId,
|
browserId: browser.browserId,
|
||||||
workspaceId: "/repo",
|
workspaceId: "wks_workspace_a",
|
||||||
url: browser.url,
|
url: browser.url,
|
||||||
title: browser.title,
|
title: browser.title,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -116,151 +247,268 @@ 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", () => {
|
describe("mountBrowserAutomationHandler", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
browserAutomationStorage.clear();
|
||||||
useBrowserStore.setState({ browsersById: {} });
|
useBrowserStore.setState({ browsersById: {} });
|
||||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
|
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
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({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspaceId: "/repo",
|
workspaceId: "wks_workspace_a",
|
||||||
});
|
});
|
||||||
if (!workspaceKey) throw new Error("expected workspace key");
|
if (!workspaceKey) {
|
||||||
const focusedTabId = useWorkspaceLayoutStore
|
throw new Error("Expected workspace key");
|
||||||
|
}
|
||||||
|
const previousFocusedTabId = useWorkspaceLayoutStore
|
||||||
.getState()
|
.getState()
|
||||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||||
if (!focusedTabId) throw new Error("expected focused tab");
|
browser.mount({ serverId: "server-1" });
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () =>
|
|
||||||
({
|
|
||||||
browser: {
|
|
||||||
executeAutomationCommand,
|
|
||||||
registerWorkspaceBrowser,
|
|
||||||
setWorkspaceActiveBrowser,
|
|
||||||
setAgentActiveBrowser,
|
|
||||||
},
|
|
||||||
}) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
const payload = client.sentResponses[0]?.payload;
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
expect(payload?.ok).toBe(true);
|
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
|
||||||
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 } }),
|
|
||||||
);
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(layout?.root.kind).toBe("pane");
|
expect(openedTabs).toEqual([
|
||||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
expect.objectContaining({
|
||||||
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
|
target: { kind: "browser", browserId: result.browserId },
|
||||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
}),
|
||||||
browserId: payload.result.browserId,
|
]);
|
||||||
workspaceId: "/repo",
|
expect(layout?.root).toEqual(
|
||||||
});
|
expect.objectContaining({
|
||||||
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
|
kind: "pane",
|
||||||
agentId: "agent-1",
|
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
|
||||||
browserId: payload.result.browserId,
|
}),
|
||||||
});
|
);
|
||||||
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
|
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
||||||
browserId: payload.result.browserId,
|
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||||
url: "https://example.com",
|
]);
|
||||||
});
|
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
expect(browser.resident.ensuredWebviews).toEqual([
|
||||||
|
{ browserId: result.browserId, url: "https://example.com" },
|
||||||
|
]);
|
||||||
|
expect(browser.browser.executedRequests).toEqual([
|
||||||
|
{
|
||||||
|
type: "browser.automation.execute.request",
|
||||||
|
requestId: "req-new:list_tabs",
|
||||||
|
agentId: "agent-1",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
command: { command: "list_tabs", args: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns browser_timeout when the resident webview does not register", async () => {
|
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
browser.browser.response = emptyListTabsPayload();
|
||||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
browser.mount({
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
registrationWaitTimeoutMs: 1,
|
registrationWaitTimeoutMs: 1,
|
||||||
registrationPollIntervalMs: 1,
|
registrationPollIntervalMs: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await waitForAsyncWork();
|
await waitForRegistrationTimeout();
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
expect(browser.client.sentResponses).toEqual([
|
||||||
requestId: "req-new",
|
{
|
||||||
ok: false,
|
type: "browser.automation.execute.response",
|
||||||
error: {
|
payload: {
|
||||||
code: "browser_timeout",
|
requestId: "req-new",
|
||||||
retryable: true,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_timeout",
|
||||||
|
message: expect.stringContaining("Timed out waiting for browser tab"),
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
expect(browser.resident.ensuredWebviews).toEqual([
|
||||||
expect.objectContaining({ url: "https://example.com" }),
|
expect.objectContaining({ url: "https://example.com" }),
|
||||||
);
|
]);
|
||||||
expect(client.sentResponses[0]?.payload).not.toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
result: { command: "new_tab" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wraps browser_new_tab registration bridge errors in a response", async () => {
|
test("browser_new_tab wraps registration bridge errors in a response", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => {
|
browser.browser.thrownError = new Error("IPC registration check failed");
|
||||||
throw new Error("IPC registration check failed");
|
browser.mount({ serverId: "server-1" });
|
||||||
});
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
expect(browser.client.sentResponses).toEqual([
|
||||||
requestId: "req-new",
|
{
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-new",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_unknown_error",
|
||||||
|
message: "IPC registration check failed",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser_resize updates resident webview dimensions", async () => {
|
||||||
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
|
browser.mount({ serverId: "server-1" });
|
||||||
|
|
||||||
|
browser.receive(browserNewTabRequest());
|
||||||
|
await flushAsyncWork();
|
||||||
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
|
|
||||||
|
browser.receive(browserResizeRequest(result.browserId));
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(browser.client.payloadAt(1)).toEqual({
|
||||||
|
requestId: "req-resize",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "resize",
|
||||||
|
browserId: result.browserId,
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(browser.browser.executedRequests).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser_resize returns not found for a tab outside the request workspace", async () => {
|
||||||
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
|
browser.mount({ serverId: "server-1" });
|
||||||
|
|
||||||
|
browser.receive(browserNewTabRequest());
|
||||||
|
await flushAsyncWork();
|
||||||
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
|
|
||||||
|
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(browser.client.payloadAt(1)).toEqual({
|
||||||
|
requestId: "req-resize",
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code: "browser_unknown_error",
|
code: "browser_tab_not_found",
|
||||||
message: "IPC registration check failed",
|
message: `No browser tab found for ID: ${result.browserId}`,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends a success response from the desktop bridge", async () => {
|
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
requestId: "req-1",
|
serverId: "server-1",
|
||||||
ok: true as const,
|
workspaceId: "wks_workspace_a",
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
});
|
||||||
|
if (!workspaceKey) {
|
||||||
|
throw new Error("Expected workspace key");
|
||||||
|
}
|
||||||
|
browser.mount({ serverId: "server-1" });
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
|
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
browser.receive(browserCloseTabRequest(result.browserId));
|
||||||
expect(client.sentResponses).toEqual([
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(browser.client.payloadAt(1)).toEqual({
|
||||||
|
requestId: "req-close-tab",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "close_tab", browserId: result.browserId },
|
||||||
|
});
|
||||||
|
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
|
||||||
|
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
|
||||||
|
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
|
||||||
|
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
|
||||||
|
expect(currentBrowserTabs()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser_close_tab returns not found after the tab is gone", async () => {
|
||||||
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
|
browser.mount({ serverId: "server-1" });
|
||||||
|
|
||||||
|
browser.receive(browserNewTabRequest());
|
||||||
|
await flushAsyncWork();
|
||||||
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
|
|
||||||
|
browser.receive(browserCloseTabRequest(result.browserId));
|
||||||
|
await flushAsyncWork();
|
||||||
|
browser.receive(browserCloseTabRequest(result.browserId));
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(browser.client.payloadAt(2)).toEqual({
|
||||||
|
requestId: "req-close-tab",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_tab_not_found",
|
||||||
|
message: `No browser tab found for ID: ${result.browserId}`,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-new-tab requests send the desktop bridge response", async () => {
|
||||||
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
|
browser.browser.response = {
|
||||||
|
requestId: "desktop-req",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "list_tabs", tabs: [] },
|
||||||
|
};
|
||||||
|
browser.mount();
|
||||||
|
const request = browserAutomationRequest();
|
||||||
|
|
||||||
|
browser.receive(request);
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(browser.browser.executedRequests).toEqual([request]);
|
||||||
|
expect(browser.client.sentResponses).toEqual([
|
||||||
{
|
{
|
||||||
type: "browser.automation.execute.response",
|
type: "browser.automation.execute.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -272,14 +520,14 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing bridge sends browser_unsupported", async () => {
|
test("missing desktop bridge sends browser_unsupported", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
browser.mount({ host: null });
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserAutomationRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(client.sentResponses).toEqual([
|
expect(browser.client.sentResponses).toEqual([
|
||||||
{
|
{
|
||||||
type: "browser.automation.execute.response",
|
type: "browser.automation.execute.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -287,7 +535,7 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code: "browser_unsupported",
|
code: "browser_unsupported",
|
||||||
message: "Desktop browser automation is not available in this app runtime.",
|
message: "Browser automation is not available in this app runtime.",
|
||||||
retryable: false,
|
retryable: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -296,80 +544,68 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("typed bridge errors become failure responses", async () => {
|
test("typed bridge errors become failure responses", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({
|
browser.browser.thrownError = {
|
||||||
client,
|
code: "browser_tab_not_found",
|
||||||
getHost: () => ({
|
message: "Browser tab browser-1 was not found.",
|
||||||
browser: {
|
retryable: false,
|
||||||
executeAutomationCommand: async () => {
|
};
|
||||||
throw {
|
browser.mount();
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
browser.receive(browserAutomationRequest());
|
||||||
retryable: false,
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({
|
browser.browser.thrownError = new Error(
|
||||||
client,
|
'No handler registered for "paseo:browser:execute-automation-command"',
|
||||||
getHost: () => ({
|
);
|
||||||
browser: {
|
browser.mount();
|
||||||
executeAutomationCommand: async () => {
|
|
||||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 requests", async () => {
|
test("unsubscribe stops handling browser automation requests", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
browser.mount();
|
||||||
requestId: "req-1",
|
|
||||||
ok: true as const,
|
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
const unsubscribe = mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe();
|
browser.unmount();
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserAutomationRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
expect(browser.browser.executedRequests).toEqual([]);
|
||||||
expect(client.sentResponses).toEqual([]);
|
expect(browser.client.sentResponses).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
||||||
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
|
import {
|
||||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
|
||||||
|
removeResidentBrowserWebview,
|
||||||
|
resizeResidentBrowserWebview,
|
||||||
|
} from "@/components/browser-webview-resident";
|
||||||
|
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
|
collectAllTabs,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ export function mountBrowserAutomationHandler(
|
|||||||
options: BrowserAutomationHandlerOptions,
|
options: BrowserAutomationHandlerOptions,
|
||||||
): () => void {
|
): () => void {
|
||||||
const getHost = options.getHost ?? getDesktopHost;
|
const getHost = options.getHost ?? getDesktopHost;
|
||||||
return options.client.on("browser.automation.execute.request", (request) => {
|
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
|
||||||
void handleBrowserAutomationRequest({
|
void handleBrowserAutomationRequest({
|
||||||
client: options.client,
|
client: options.client,
|
||||||
getHost,
|
getHost,
|
||||||
@@ -56,6 +61,9 @@ export function mountBrowserAutomationHandler(
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mountBrowserAutomationDaemonClientHandler(
|
export function mountBrowserAutomationDaemonClientHandler(
|
||||||
@@ -111,7 +119,32 @@ async function handleBrowserAutomationRequest(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rememberAgentBrowserTarget({ request, browserHost });
|
if (request.command.command === "resize") {
|
||||||
|
client.sendBrowserAutomationExecuteResponse({
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: resizeBrowserTabForRequest({ request, serverId }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.command.command === "close_tab") {
|
||||||
|
try {
|
||||||
|
client.sendBrowserAutomationExecuteResponse({
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: await closeBrowserTabForRequest({
|
||||||
|
request,
|
||||||
|
serverId,
|
||||||
|
browserHost,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
client.sendBrowserAutomationExecuteResponse({
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!executeAutomationCommand) {
|
if (!executeAutomationCommand) {
|
||||||
client.sendBrowserAutomationExecuteResponse({
|
client.sendBrowserAutomationExecuteResponse({
|
||||||
@@ -119,7 +152,7 @@ async function handleBrowserAutomationRequest(params: {
|
|||||||
payload: browserAutomationFailure({
|
payload: browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_unsupported",
|
code: "browser_unsupported",
|
||||||
message: "Desktop browser automation is not available in this app runtime.",
|
message: "Browser automation is not available in this app runtime.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -139,6 +172,127 @@ 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: {
|
async function openBrowserTabForRequest(params: {
|
||||||
request: BrowserAutomationExecuteRequest;
|
request: BrowserAutomationExecuteRequest;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -159,11 +313,11 @@ async function openBrowserTabForRequest(params: {
|
|||||||
BrowserAutomationExecuteRequest["command"],
|
BrowserAutomationExecuteRequest["command"],
|
||||||
{ command: "new_tab" }
|
{ command: "new_tab" }
|
||||||
>;
|
>;
|
||||||
const workspaceId = request.workspaceId ?? command.args.workspaceId;
|
const workspaceId = request.workspaceId;
|
||||||
if (!serverId || !workspaceId) {
|
if (!serverId || !workspaceId) {
|
||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_no_tab",
|
code: "browser_unsupported",
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
message: "Cannot create a browser tab without a workspace context.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,7 +328,7 @@ async function openBrowserTabForRequest(params: {
|
|||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_no_tab",
|
code: "browser_unsupported",
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
message: "Cannot create a browser tab without a workspace context.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,9 +338,6 @@ async function openBrowserTabForRequest(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||||
if (request.agentId) {
|
|
||||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browserHost?.executeAutomationCommand) {
|
if (browserHost?.executeAutomationCommand) {
|
||||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||||
@@ -204,7 +355,7 @@ async function openBrowserTabForRequest(params: {
|
|||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
code: "browser_timeout",
|
code: "browser_timeout",
|
||||||
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
|
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -235,7 +386,7 @@ async function waitForBrowserRegistration(params: {
|
|||||||
agentId: params.request.agentId,
|
agentId: params.request.agentId,
|
||||||
cwd: params.request.cwd,
|
cwd: params.request.cwd,
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
|
command: { command: "list_tabs", args: {} },
|
||||||
});
|
});
|
||||||
if (payload.ok && payload.result.command === "list_tabs") {
|
if (payload.ok && payload.result.command === "list_tabs") {
|
||||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||||
@@ -251,28 +402,6 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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(
|
function normalizeBridgePayload(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
payload: BrowserAutomationResponsePayload,
|
payload: BrowserAutomationResponsePayload,
|
||||||
@@ -294,14 +423,14 @@ function normalizeThrownBridgeError(
|
|||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId,
|
requestId,
|
||||||
code: "browser_unsupported",
|
code: "browser_unsupported",
|
||||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
message: "Browser automation is not implemented by this app build yet.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return browserAutomationFailure({
|
return browserAutomationFailure({
|
||||||
requestId,
|
requestId,
|
||||||
code: "browser_unknown_error",
|
code: "browser_unknown_error",
|
||||||
message: message || "Desktop browser automation failed.",
|
message: message || "Browser automation failed.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
|
import { createControlGeometry } from "@/components/ui/control-geometry";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -220,7 +221,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
adaptiveInputOutline: {
|
adaptiveInputOutline: {
|
||||||
outlineColor: theme.colors.accent,
|
...createControlGeometry(theme).controlFocusRingColor,
|
||||||
},
|
},
|
||||||
adaptiveInputText: {
|
adaptiveInputText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -231,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||||
|
const WEB_EXIT_DURATION_MS = 160;
|
||||||
|
|
||||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -448,6 +450,7 @@ export interface AdaptiveModalSheetProps {
|
|||||||
header: SheetHeader;
|
header: SheetHeader;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Sticky footer rendered below the scrollable content. */
|
/** Sticky footer rendered below the scrollable content. */
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
@@ -468,6 +471,7 @@ export function AdaptiveModalSheet({
|
|||||||
header,
|
header,
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
|
onDismiss,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
@@ -533,6 +537,20 @@ export function AdaptiveModalSheet({
|
|||||||
isEnabled: isMobile,
|
isEnabled: isMobile,
|
||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
|
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||||
|
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||||
|
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
handleSheetDismiss();
|
||||||
|
onDismiss?.();
|
||||||
|
}, [handleSheetDismiss, onDismiss]);
|
||||||
|
const notifyNativeModalDismiss = useCallback(() => {
|
||||||
|
if (nativeModalDismissNotifiedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nativeModalDismissNotifiedRef.current = true;
|
||||||
|
onDismiss?.();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -545,12 +563,53 @@ export function AdaptiveModalSheet({
|
|||||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||||
[desktopMaxWidth],
|
[desktopMaxWidth],
|
||||||
);
|
);
|
||||||
|
const desktopOverlayStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.desktopOverlay,
|
||||||
|
isWeb && {
|
||||||
|
opacity: isWebClosing ? 0 : 1,
|
||||||
|
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
||||||
|
transitionProperty: "opacity",
|
||||||
|
transitionTimingFunction: "ease",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[isWebClosing],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || isMobile || !visible) return;
|
if (!isWeb || isMobile || !visible) return;
|
||||||
return pushEscHandler(onClose);
|
return pushEscHandler(onClose);
|
||||||
}, [visible, isMobile, onClose]);
|
}, [visible, isMobile, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
nativeModalDismissNotifiedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || isMobile) return;
|
||||||
|
if (visible) {
|
||||||
|
setShouldRenderWeb(true);
|
||||||
|
setIsWebClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldRenderWeb) return;
|
||||||
|
setIsWebClosing(true);
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setShouldRenderWeb(false);
|
||||||
|
setIsWebClosing(false);
|
||||||
|
onDismiss?.();
|
||||||
|
}, WEB_EXIT_DURATION_MS);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
||||||
|
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<IsolatedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
@@ -559,7 +618,7 @@ export function AdaptiveModalSheet({
|
|||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
onDismiss={handleSheetDismiss}
|
onDismiss={handleDismiss}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
@@ -614,7 +673,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const desktopContent = (
|
const desktopContent = (
|
||||||
<View style={styles.desktopOverlay} testID={testID}>
|
<View style={desktopOverlayStyle} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityLabel={t("common.actions.dismiss")}
|
accessibilityLabel={t("common.actions.dismiss")}
|
||||||
style={ABSOLUTE_FILL_STYLE}
|
style={ABSOLUTE_FILL_STYLE}
|
||||||
@@ -626,7 +685,7 @@ export function AdaptiveModalSheet({
|
|||||||
|
|
||||||
// On web, use portal to overlay root for consistent stacking with toasts
|
// On web, use portal to overlay root for consistent stacking with toasts
|
||||||
if (isWeb && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
if (!visible) return null;
|
if (!shouldRenderWeb) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +695,7 @@ export function AdaptiveModalSheet({
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
|
onDismiss={notifyNativeModalDismiss}
|
||||||
hardwareAccelerated
|
hardwareAccelerated
|
||||||
>
|
>
|
||||||
{desktopContent}
|
{desktopContent}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import {
|
import {
|
||||||
formatAppDiagnosticHeader,
|
formatAppDiagnosticHeader,
|
||||||
formatDiagnosticSection,
|
formatDiagnosticSection,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
formatServerInfoSection,
|
formatServerInfoSection,
|
||||||
redactAppDiagnosticReport,
|
redactAppDiagnosticReport,
|
||||||
} from "@/diagnostics/app-diagnostic-report";
|
} from "@/diagnostics/app-diagnostic-report";
|
||||||
|
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
|
||||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
@@ -261,35 +261,6 @@ export function AppDiagnosticSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
|
|
||||||
try {
|
|
||||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
|
||||||
return {
|
|
||||||
status: "done",
|
|
||||||
sections: [
|
|
||||||
formatDiagnosticSection("Desktop", [
|
|
||||||
{ label: "Daemon status", value: status.status },
|
|
||||||
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
|
||||||
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
|
||||||
{ label: "Daemon version", value: status.version ?? "unknown" },
|
|
||||||
{ label: "Daemon home", value: status.home || "unknown" },
|
|
||||||
{ label: "Log path", value: logs.logPath || "unknown" },
|
|
||||||
{ label: "Error", value: status.error ?? "none" },
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
"Desktop daemon log tail",
|
|
||||||
logs.contents ? indentBlock(logs.contents) : " No log lines found",
|
|
||||||
].join("\n"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
status: "failed",
|
|
||||||
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectHostDiagnosticSections(
|
async function collectHostDiagnosticSections(
|
||||||
host: HostProfile,
|
host: HostProfile,
|
||||||
snapshot: HostRuntimeSnapshot | null,
|
snapshot: HostRuntimeSnapshot | null,
|
||||||
@@ -338,14 +309,6 @@ async function collectHostDiagnosticSections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function indentBlock(value: string): string {
|
|
||||||
return value
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => ` ${line}`)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMessage(error: unknown): string {
|
function toMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,22 @@ import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
|
||||||
Maximize,
|
Maximize,
|
||||||
Monitor,
|
Monitor,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
PencilRuler,
|
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Tablet,
|
Tablet,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import {
|
import {
|
||||||
@@ -40,11 +41,7 @@ import {
|
|||||||
useWorkspaceAttachments,
|
useWorkspaceAttachments,
|
||||||
useWorkspaceAttachmentsStore,
|
useWorkspaceAttachmentsStore,
|
||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import type {
|
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
|
||||||
AttachmentMetadata,
|
|
||||||
BrowserAnnotationIntent,
|
|
||||||
BrowserElementAttachment,
|
|
||||||
} from "@/attachments/types";
|
|
||||||
import { persistAttachmentFromDataUrl } from "@/attachments/service";
|
import { persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +49,6 @@ import {
|
|||||||
isElectronRuntime,
|
isElectronRuntime,
|
||||||
type DesktopBrowserShortcutEvent,
|
type DesktopBrowserShortcutEvent,
|
||||||
} from "@/desktop/host";
|
} from "@/desktop/host";
|
||||||
import { isDev } from "@/constants/platform";
|
|
||||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
||||||
import {
|
import {
|
||||||
prepareBrowserWebview,
|
prepareBrowserWebview,
|
||||||
@@ -79,25 +75,14 @@ type WebTextInput = TextInput & {
|
|||||||
getNativeRef?: () => unknown;
|
getNativeRef?: () => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserElementSelection = Omit<
|
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
|
||||||
BrowserElementAttachment,
|
|
||||||
"formatted" | "comment" | "intent"
|
|
||||||
> & {
|
|
||||||
attributes?: Record<string, string>;
|
attributes?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BrowserElementAnnotation {
|
interface BrowserElementAnnotation {
|
||||||
comment: string;
|
comment: string;
|
||||||
intent: BrowserAnnotationIntent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BROWSER_ANNOTATION_INTENTS: readonly BrowserAnnotationIntent[] = [
|
|
||||||
"fix",
|
|
||||||
"change",
|
|
||||||
"question",
|
|
||||||
"approve",
|
|
||||||
];
|
|
||||||
|
|
||||||
type DeviceSizeId =
|
type DeviceSizeId =
|
||||||
| "responsive"
|
| "responsive"
|
||||||
| "iphone-se"
|
| "iphone-se"
|
||||||
@@ -223,10 +208,6 @@ function formatElementAttachment(
|
|||||||
const html = truncateText(selection.outerHTML.trim(), 800);
|
const html = truncateText(selection.outerHTML.trim(), 800);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (annotation) {
|
|
||||||
parts.push(`intent: ${annotation.intent}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.reactSource?.fileName) {
|
if (selection.reactSource?.fileName) {
|
||||||
const loc = [
|
const loc = [
|
||||||
selection.reactSource.fileName,
|
selection.reactSource.fileName,
|
||||||
@@ -289,7 +270,6 @@ function buildBrowserElementAttachment(
|
|||||||
parentChain: selection.parentChain,
|
parentChain: selection.parentChain,
|
||||||
children: selection.children,
|
children: selection.children,
|
||||||
...(comment ? { comment } : {}),
|
...(comment ? { comment } : {}),
|
||||||
...(annotation ? { intent: annotation.intent } : {}),
|
|
||||||
...(screenshot ? { screenshot } : {}),
|
...(screenshot ? { screenshot } : {}),
|
||||||
formatted: formatElementAttachment(selection, annotation),
|
formatted: formatElementAttachment(selection, annotation),
|
||||||
};
|
};
|
||||||
@@ -641,10 +621,11 @@ export function BrowserPane({
|
|||||||
const pendingNavigationUrlRef = useRef<string | null>(null);
|
const pendingNavigationUrlRef = useRef<string | null>(null);
|
||||||
const domReadyRef = useRef(false);
|
const domReadyRef = useRef(false);
|
||||||
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
|
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
|
||||||
const [selectorActive, setSelectorActive] = useState(false);
|
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
|
||||||
|
const selectorActive = selectorMode !== null;
|
||||||
// Which action the active selector performs on click: open the annotation card
|
// Which action the active selector performs on click: open the annotation card
|
||||||
// ("annotate") or copy the element to the clipboard ("grab").
|
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
|
||||||
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
|
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const toastRef = useRef(toast);
|
const toastRef = useRef(toast);
|
||||||
toastRef.current = toast;
|
toastRef.current = toast;
|
||||||
@@ -1074,7 +1055,7 @@ export function BrowserPane({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const grabElementToClipboard = useCallback(
|
const screenshotElementToClipboard = useCallback(
|
||||||
async (selection: BrowserElementSelection) => {
|
async (selection: BrowserElementSelection) => {
|
||||||
const text = formatElementAttachment(selection);
|
const text = formatElementAttachment(selection);
|
||||||
const copyElement = getDesktopHost()?.browser?.copyElement;
|
const copyElement = getDesktopHost()?.browser?.copyElement;
|
||||||
@@ -1087,19 +1068,23 @@ export function BrowserPane({
|
|||||||
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
|
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
|
||||||
imageDataUrl = dataUrl ?? undefined;
|
imageDataUrl = dataUrl ?? undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[browser-pane] capture element for grab failed", error);
|
console.warn("[browser-pane] capture element for screenshot 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
|
// Copy via the main process; the renderer's navigator.clipboard rejects
|
||||||
// with NotAllowedError because focus is inside the guest <webview>.
|
// with NotAllowedError because focus is inside the guest <webview>.
|
||||||
if (typeof copyElement === "function") {
|
if (typeof copyElement === "function") {
|
||||||
try {
|
try {
|
||||||
const ok = await copyElement({ text, imageDataUrl });
|
const ok = await copyElement({ text, imageDataUrl });
|
||||||
if (ok) {
|
if (ok) {
|
||||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
toastRef.current?.show(copiedMessage, { variant: "success" });
|
||||||
} else {
|
} else {
|
||||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1110,10 +1095,12 @@ export function BrowserPane({
|
|||||||
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
|
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
|
||||||
try {
|
try {
|
||||||
await Clipboard.setStringAsync(text);
|
await Clipboard.setStringAsync(text);
|
||||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[browser-pane] clipboard fallback failed", error);
|
console.warn("[browser-pane] clipboard fallback failed", error);
|
||||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
@@ -1121,8 +1108,8 @@ export function BrowserPane({
|
|||||||
|
|
||||||
const handleSelectorResult = useCallback(
|
const handleSelectorResult = useCallback(
|
||||||
(selection: BrowserElementSelection) => {
|
(selection: BrowserElementSelection) => {
|
||||||
if (selectorModeRef.current === "grab") {
|
if (selectorModeRef.current === "screenshot") {
|
||||||
void grabElementToClipboard(selection);
|
void screenshotElementToClipboard(selection);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingScreenshotRef.current = undefined;
|
pendingScreenshotRef.current = undefined;
|
||||||
@@ -1132,7 +1119,7 @@ export function BrowserPane({
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[captureElementScreenshot, grabElementToClipboard],
|
[captureElementScreenshot, screenshotElementToClipboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitAnnotation = useCallback(
|
const submitAnnotation = useCallback(
|
||||||
@@ -1155,15 +1142,15 @@ export function BrowserPane({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startElementSelector = useCallback(
|
const startElementSelector = useCallback(
|
||||||
(mode: "annotate" | "grab") => {
|
(mode: "annotate" | "screenshot") => {
|
||||||
const webview = webviewRef.current;
|
const webview = webviewRef.current;
|
||||||
if (!webview || !domReadyRef.current) return;
|
if (!webview || !domReadyRef.current) return;
|
||||||
// Annotate needs a workspace scope to attach to; grab only copies.
|
// Annotate needs a workspace scope to attach to; screenshot only copies.
|
||||||
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
||||||
selectorModeRef.current = mode;
|
selectorModeRef.current = mode;
|
||||||
pendingScreenshotRef.current = undefined;
|
pendingScreenshotRef.current = undefined;
|
||||||
setPendingSelection(null);
|
setPendingSelection(null);
|
||||||
setSelectorActive(true);
|
setSelectorMode(mode);
|
||||||
|
|
||||||
const js = `
|
const js = `
|
||||||
(function() {
|
(function() {
|
||||||
@@ -1389,11 +1376,11 @@ export function BrowserPane({
|
|||||||
const poll = startSelectorResultPolling({
|
const poll = startSelectorResultPolling({
|
||||||
webview,
|
webview,
|
||||||
onSelection: handleSelectorResult,
|
onSelection: handleSelectorResult,
|
||||||
onDone: () => setSelectorActive(false),
|
onDone: () => setSelectorMode(null),
|
||||||
});
|
});
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.clearInterval(poll);
|
window.clearInterval(poll);
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
if (webviewRef.current !== webview || !domReadyRef.current) {
|
if (webviewRef.current !== webview || !domReadyRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1402,10 +1389,10 @@ export function BrowserPane({
|
|||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSelectorResult, workspaceAttachmentScopeKey],
|
[handleSelectorResult, workspaceAttachmentScopeKey],
|
||||||
@@ -1413,7 +1400,7 @@ export function BrowserPane({
|
|||||||
|
|
||||||
const cancelElementSelector = useCallback(() => {
|
const cancelElementSelector = useCallback(() => {
|
||||||
const webview = webviewRef.current;
|
const webview = webviewRef.current;
|
||||||
setSelectorActive(false);
|
setSelectorMode(null);
|
||||||
if (webview && domReadyRef.current) {
|
if (webview && domReadyRef.current) {
|
||||||
try {
|
try {
|
||||||
clearWebviewSelector(webview);
|
clearWebviewSelector(webview);
|
||||||
@@ -1470,12 +1457,12 @@ export function BrowserPane({
|
|||||||
startElementSelector("annotate");
|
startElementSelector("annotate");
|
||||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||||
|
|
||||||
const handleToggleGrab = useCallback(() => {
|
const handleToggleScreenshot = useCallback(() => {
|
||||||
if (selectorActive) {
|
if (selectorActive) {
|
||||||
cancelElementSelector();
|
cancelElementSelector();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startElementSelector("grab");
|
startElementSelector("screenshot");
|
||||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||||
|
|
||||||
const handleOpenDevTools = useCallback(() => {
|
const handleOpenDevTools = useCallback(() => {
|
||||||
@@ -1521,13 +1508,21 @@ export function BrowserPane({
|
|||||||
],
|
],
|
||||||
[browser?.canGoForward],
|
[browser?.canGoForward],
|
||||||
);
|
);
|
||||||
const selectorIconButtonStyle = useCallback(
|
const annotateIconButtonStyle = useCallback(
|
||||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||||
styles.iconButton,
|
styles.iconButton,
|
||||||
selectorActive && styles.selectorActiveButton,
|
selectorMode === "annotate" && styles.selectorActiveButton,
|
||||||
(hovered || pressed) && styles.iconButtonHovered,
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
],
|
],
|
||||||
[selectorActive],
|
[selectorMode],
|
||||||
|
);
|
||||||
|
const screenshotIconButtonStyle = useCallback(
|
||||||
|
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||||
|
styles.iconButton,
|
||||||
|
selectorMode === "screenshot" && styles.selectorActiveButton,
|
||||||
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
|
],
|
||||||
|
[selectorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const devicePreset = useMemo(
|
const devicePreset = useMemo(
|
||||||
@@ -1633,36 +1628,46 @@ export function BrowserPane({
|
|||||||
onSelect={setDeviceSizeId}
|
onSelect={setDeviceSizeId}
|
||||||
triggerStyle={baseIconButtonStyle}
|
triggerStyle={baseIconButtonStyle}
|
||||||
/>
|
/>
|
||||||
{isDev ? (
|
<ToolbarButton
|
||||||
<ToolbarButton
|
label={t("workspace.browser.controls.openDevTools")}
|
||||||
label={t("workspace.browser.controls.openDevTools")}
|
onPress={handleOpenDevTools}
|
||||||
onPress={handleOpenDevTools}
|
style={baseIconButtonStyle}
|
||||||
style={baseIconButtonStyle}
|
>
|
||||||
>
|
<Wrench size={16} color={theme.colors.foregroundMuted} />
|
||||||
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
|
</ToolbarButton>
|
||||||
</ToolbarButton>
|
|
||||||
) : null}
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label={
|
label={
|
||||||
selectorActive
|
selectorMode === "annotate"
|
||||||
? t("workspace.browser.controls.cancelSelector")
|
? t("workspace.browser.controls.cancelSelector")
|
||||||
: t("workspace.browser.controls.selectElement")
|
: t("workspace.browser.controls.annotateElement")
|
||||||
}
|
}
|
||||||
active={selectorActive}
|
active={selectorMode === "annotate"}
|
||||||
onPress={handleToggleElementSelector}
|
onPress={handleToggleElementSelector}
|
||||||
style={selectorIconButtonStyle}
|
style={annotateIconButtonStyle}
|
||||||
>
|
>
|
||||||
<MousePointer2
|
<MousePointer2
|
||||||
size={16}
|
size={16}
|
||||||
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
|
color={
|
||||||
|
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label={t("workspace.browser.controls.grabElement")}
|
label={
|
||||||
onPress={handleToggleGrab}
|
selectorMode === "screenshot"
|
||||||
style={baseIconButtonStyle}
|
? t("workspace.browser.controls.cancelSelector")
|
||||||
|
: t("workspace.browser.controls.screenshotElement")
|
||||||
|
}
|
||||||
|
active={selectorMode === "screenshot"}
|
||||||
|
onPress={handleToggleScreenshot}
|
||||||
|
style={screenshotIconButtonStyle}
|
||||||
>
|
>
|
||||||
<Copy size={16} color={theme.colors.foregroundMuted} />
|
<Camera
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1690,45 +1695,6 @@ 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({
|
function BrowserElementAnnotationCard({
|
||||||
selection,
|
selection,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -1740,14 +1706,11 @@ function BrowserElementAnnotationCard({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [intent, setIntent] = useState<BrowserAnnotationIntent>("fix");
|
|
||||||
const commentRef = useRef(comment);
|
const commentRef = useRef(comment);
|
||||||
commentRef.current = comment;
|
commentRef.current = comment;
|
||||||
const intentRef = useRef(intent);
|
|
||||||
intentRef.current = intent;
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
onSubmit({ comment: commentRef.current, intent: intentRef.current });
|
onSubmit({ comment: commentRef.current });
|
||||||
}, [onSubmit]);
|
}, [onSubmit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1792,17 +1755,6 @@ function BrowserElementAnnotationCard({
|
|||||||
<Text numberOfLines={1} style={styles.annotationElement}>
|
<Text numberOfLines={1} style={styles.annotationElement}>
|
||||||
{elementLabel}
|
{elementLabel}
|
||||||
</Text>
|
</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
|
<ThemedAnnotationInput
|
||||||
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
|
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -1814,24 +1766,12 @@ function BrowserElementAnnotationCard({
|
|||||||
value={comment}
|
value={comment}
|
||||||
/>
|
/>
|
||||||
<View style={styles.annotationActions}>
|
<View style={styles.annotationActions}>
|
||||||
<Pressable
|
<Button variant="ghost" size="sm" onPress={onCancel}>
|
||||||
accessibilityRole="button"
|
{t("workspace.browser.annotate.cancel")}
|
||||||
onPress={onCancel}
|
</Button>
|
||||||
style={styles.annotationSecondaryButton}
|
<Button variant="default" size="sm" onPress={handleSubmit}>
|
||||||
>
|
{t("workspace.browser.annotate.submit")}
|
||||||
<Text style={styles.annotationSecondaryText}>
|
</Button>
|
||||||
{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>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1986,31 +1926,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
annotationElement: {
|
annotationElement: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
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: {
|
annotationInput: {
|
||||||
minHeight: 64,
|
minHeight: 64,
|
||||||
@@ -2029,27 +1945,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
gap: theme.spacing[2],
|
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: {
|
unavailableState: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -2,33 +2,66 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
clearResidentBrowserWebviewsForTests,
|
clearResidentBrowserWebviewsForTests,
|
||||||
ensureResidentBrowserWebview,
|
ensureResidentBrowserWebview,
|
||||||
|
prepareBrowserWebview,
|
||||||
releaseResidentBrowserWebview,
|
releaseResidentBrowserWebview,
|
||||||
removeResidentBrowserWebview,
|
removeResidentBrowserWebview,
|
||||||
takeResidentBrowserWebview,
|
takeResidentBrowserWebview,
|
||||||
} from "./browser-webview-resident";
|
} 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", () => {
|
describe("resident browser webviews", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearResidentBrowserWebviewsForTests();
|
clearResidentBrowserWebviewsForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
|
it("parks a browser webview in the permanent paintable 1x1 host", () => {
|
||||||
const host = document.createElement("div");
|
const visibleHost = document.createElement("div");
|
||||||
const webview = document.createElement("webview");
|
const webview = document.createElement("webview");
|
||||||
host.appendChild(webview);
|
visibleHost.appendChild(webview);
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(visibleHost);
|
||||||
|
|
||||||
releaseResidentBrowserWebview("browser-a", webview);
|
releaseResidentBrowserWebview("browser-a", webview);
|
||||||
|
|
||||||
expect(host.children).toHaveLength(0);
|
const host = residentHost();
|
||||||
|
expect(visibleHost.children).toHaveLength(0);
|
||||||
|
expect(Array.from(host.children)).toEqual([webview]);
|
||||||
expect(webview.isConnected).toBe(true);
|
expect(webview.isConnected).toBe(true);
|
||||||
expect(webview.style.width).toBe("1280px");
|
expectPermanentHostParking(host);
|
||||||
expect(webview.style.height).toBe("800px");
|
expectResidentWebviewParking(webview);
|
||||||
|
|
||||||
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", () => {
|
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||||
@@ -44,6 +77,113 @@ describe("resident browser webviews", () => {
|
|||||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||||
"https://example.com",
|
"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", () => {
|
it("removes a resident webview when its browser tab closes", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const RESIDENT_VIEWPORT_WIDTH = 1280;
|
|||||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||||
|
|
||||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||||
|
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
||||||
|
|
||||||
interface BrowserWebviewElement extends HTMLElement {
|
interface BrowserWebviewElement extends HTMLElement {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -21,42 +22,84 @@ function readDocument(): Document | null {
|
|||||||
return typeof document === "undefined" ? null : document;
|
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 {
|
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
applyResidentHostParkingStyle(existing);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = ownerDocument.createElement("div");
|
const host = ownerDocument.createElement("div");
|
||||||
host.id = RESIDENT_BROWSER_HOST_ID;
|
host.id = RESIDENT_BROWSER_HOST_ID;
|
||||||
host.setAttribute("aria-hidden", "true");
|
applyResidentHostParkingStyle(host);
|
||||||
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);
|
ownerDocument.body.appendChild(host);
|
||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
||||||
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
||||||
return element as HTMLElement;
|
return element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyResidentWebviewStyle(webview: HTMLElement): void {
|
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
|
||||||
webview.style.display = "block";
|
if (!browserId) {
|
||||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
|
||||||
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
}
|
||||||
|
return (
|
||||||
|
residentWebviewSizesByBrowserId.get(browserId) ?? {
|
||||||
|
width: RESIDENT_VIEWPORT_WIDTH,
|
||||||
|
height: RESIDENT_VIEWPORT_HEIGHT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
|
||||||
|
const dimensions = dimensionsForBrowser(browserId);
|
||||||
|
webview.style.display = "inline-flex";
|
||||||
|
webview.style.flex = "0 0 auto";
|
||||||
|
webview.style.width = `${dimensions.width}px`;
|
||||||
|
webview.style.height = `${dimensions.height}px`;
|
||||||
webview.style.border = "0";
|
webview.style.border = "0";
|
||||||
webview.style.background = "transparent";
|
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(
|
export function prepareBrowserWebview(
|
||||||
@@ -88,11 +131,15 @@ export function ensureResidentBrowserWebview(input: {
|
|||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
||||||
if (resident?.isConnected) {
|
if (resident?.isConnected) {
|
||||||
|
releaseResidentBrowserWebview(browserId, resident);
|
||||||
return resident;
|
return resident;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
const existing = findBrowserWebview(browserId, ownerDocument);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
|
||||||
|
releaseResidentBrowserWebview(browserId, existing);
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +161,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||||
|
clearResidentWebviewParkingStyle(webview);
|
||||||
return webview;
|
return webview;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +177,33 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
|
|||||||
}
|
}
|
||||||
|
|
||||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||||
applyResidentWebviewStyle(webview);
|
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||||
getResidentBrowserHost(ownerDocument).appendChild(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 {
|
export function removeResidentBrowserWebview(browserId: string): void {
|
||||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||||
if (!normalizedBrowserId) {
|
if (!normalizedBrowserId) {
|
||||||
@@ -141,6 +212,7 @@ export function removeResidentBrowserWebview(browserId: string): void {
|
|||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||||
|
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||||
resident?.remove();
|
resident?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,5 +221,6 @@ export function clearResidentBrowserWebviewsForTests(): void {
|
|||||||
webview.remove();
|
webview.remove();
|
||||||
}
|
}
|
||||||
residentWebviewsByBrowserId.clear();
|
residentWebviewsByBrowserId.clear();
|
||||||
|
residentWebviewSizesByBrowserId.clear();
|
||||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,39 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
ActivityIndicator,
|
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxItem,
|
||||||
|
type ComboboxOption,
|
||||||
|
type ComboboxProps,
|
||||||
|
} from "@/components/ui/combobox";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import {
|
||||||
|
buildSelectedTriggerLabel,
|
||||||
|
filterAndRankModelRows,
|
||||||
|
getAllProviderModelRows,
|
||||||
|
getProviderModelRows,
|
||||||
|
resolveSelectedModelLabel,
|
||||||
|
type ProviderSelectionModelRow,
|
||||||
|
type ProviderSelectorProvider,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
const IS_WEB = platformIsWeb;
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
||||||
|
|
||||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
@@ -47,22 +62,67 @@ function drillDownRowStyle({
|
|||||||
pressed && styles.drillDownRowPressed,
|
pressed && styles.drillDownRowPressed,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import {
|
|
||||||
buildSelectedTriggerLabel,
|
|
||||||
filterAndRankModelRows,
|
|
||||||
getAllProviderModelRows,
|
|
||||||
getProviderModelRows,
|
|
||||||
resolveSelectedModelLabel,
|
|
||||||
type ProviderSelectionModelRow,
|
|
||||||
type ProviderSelectorProvider,
|
|
||||||
} from "@/provider-selection/provider-selection";
|
|
||||||
|
|
||||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||||
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
||||||
|
|
||||||
|
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
|
||||||
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||||
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
const ThemedSearch = withUnistyles(Search);
|
||||||
|
const ThemedSettings = withUnistyles(Settings);
|
||||||
|
const ThemedStar = withUnistyles(Star);
|
||||||
|
|
||||||
|
const foregroundMutedMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
|
||||||
|
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoriteStarMapping =
|
||||||
|
(isFavorite: boolean, hovered: boolean) =>
|
||||||
|
(theme: Theme): { color: string; fill: string } => {
|
||||||
|
const favoriteColor = theme.colors.palette.amber[500];
|
||||||
|
if (isFavorite) {
|
||||||
|
return { color: favoriteColor, fill: favoriteColor };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
|
||||||
|
fill: "transparent",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderGlyphTone = "muted" | "foreground";
|
||||||
|
|
||||||
|
function ProviderGlyph({
|
||||||
|
provider,
|
||||||
|
size,
|
||||||
|
tone = "muted",
|
||||||
|
}: {
|
||||||
|
provider: string;
|
||||||
|
size: number;
|
||||||
|
tone?: ProviderGlyphTone;
|
||||||
|
}) {
|
||||||
|
const Icon = getProviderIcon(provider);
|
||||||
|
const color =
|
||||||
|
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
||||||
|
return <Icon size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
|
||||||
|
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
|
||||||
|
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
|
||||||
|
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
|
||||||
|
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
type SelectorView =
|
type SelectorView =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
@@ -89,6 +149,8 @@ interface CombinedModelSelectorProps {
|
|||||||
isRetryingProvider?: boolean;
|
isRetryingProvider?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
|
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
||||||
|
desktopMinWidth?: number;
|
||||||
/**
|
/**
|
||||||
* Render the custom trigger as a full-width form field: the outer Pressable
|
* 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
|
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
||||||
@@ -149,9 +211,7 @@ function ModelRow({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ProviderIcon = getProviderIcon(row.provider);
|
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
@@ -162,8 +222,8 @@ function ModelRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const leadingSlot = useMemo(
|
const leadingSlot = useMemo(
|
||||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
||||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
[row.provider],
|
||||||
);
|
);
|
||||||
const trailingSlot = useMemo(
|
const trailingSlot = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -178,32 +238,10 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||||
>
|
>
|
||||||
{({ hovered }) => {
|
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
|
||||||
let starColor: string;
|
|
||||||
if (isFavorite) starColor = theme.colors.palette.amber[500];
|
|
||||||
else if (hovered) starColor = theme.colors.foregroundMuted;
|
|
||||||
else starColor = theme.colors.border;
|
|
||||||
return (
|
|
||||||
<Star
|
|
||||||
size={16}
|
|
||||||
color={starColor}
|
|
||||||
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null,
|
) : null,
|
||||||
[
|
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
|
||||||
onToggleFavorite,
|
|
||||||
handleToggleFavorite,
|
|
||||||
isFavorite,
|
|
||||||
row.provider,
|
|
||||||
row.modelId,
|
|
||||||
theme.colors.palette.amber,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
theme.colors.border,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,9 +343,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ProvIcon = getProviderIcon(provider.id);
|
|
||||||
const selection = provider.modelSelection;
|
const selection = provider.modelSelection;
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
@@ -327,18 +363,16 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
|||||||
} else if (selection.kind === "loading") {
|
} else if (selection.kind === "loading") {
|
||||||
stateNode = (
|
stateNode = (
|
||||||
<View style={styles.rowStateInline}>
|
<View style={styles.rowStateInline}>
|
||||||
<ActivityIndicator
|
<View style={styles.rowSpinner}>
|
||||||
size="small"
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||||
color={theme.colors.foregroundMuted}
|
</View>
|
||||||
style={styles.rowSpinner}
|
|
||||||
/>
|
|
||||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
stateNode = (
|
stateNode = (
|
||||||
<View style={styles.rowStateInline}>
|
<View style={styles.rowStateInline}>
|
||||||
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||||
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -346,11 +380,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
|
||||||
<Text style={styles.drillDownText}>{provider.label}</Text>
|
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
{stateNode}
|
{stateNode}
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -446,14 +480,13 @@ function ProviderErrorEmptyState({
|
|||||||
onRetryProvider?: (provider: AgentProvider) => void;
|
onRetryProvider?: (provider: AgentProvider) => void;
|
||||||
isRetryingProvider: boolean;
|
isRetryingProvider: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
onRetryProvider?.(providerId);
|
onRetryProvider?.(providerId);
|
||||||
}, [onRetryProvider, providerId]);
|
}, [onRetryProvider, providerId]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
||||||
<Text style={styles.emptyStateText}>{message}</Text>
|
<Text style={styles.emptyStateText}>{message}</Text>
|
||||||
{onRetryProvider ? (
|
{onRetryProvider ? (
|
||||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||||
@@ -477,7 +510,6 @@ function SelectorContent({
|
|||||||
onRetryProvider,
|
onRetryProvider,
|
||||||
isRetryingProvider,
|
isRetryingProvider,
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||||
const selectedViewProvider = useMemo(
|
const selectedViewProvider = useMemo(
|
||||||
@@ -501,7 +533,7 @@ function SelectorContent({
|
|||||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
||||||
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -514,11 +546,9 @@ function SelectorContent({
|
|||||||
if (drillSelection.kind === "loading") {
|
if (drillSelection.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<ActivityIndicator
|
<View style={styles.rowSpinner}>
|
||||||
size="small"
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||||
color={theme.colors.foregroundMuted}
|
</View>
|
||||||
style={styles.rowSpinner}
|
|
||||||
/>
|
|
||||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -585,9 +615,10 @@ export function CombinedModelSelector({
|
|||||||
isRetryingProvider = false,
|
isRetryingProvider = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
serverId = null,
|
serverId = null,
|
||||||
|
desktopPlacement,
|
||||||
|
desktopMinWidth,
|
||||||
triggerFill = false,
|
triggerFill = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -643,7 +674,6 @@ export function CombinedModelSelector({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
return resolveSelectedModelLabel({
|
return resolveSelectedModelLabel({
|
||||||
@@ -750,7 +780,6 @@ export function CombinedModelSelector({
|
|||||||
if (view.kind === "all") {
|
if (view.kind === "all") {
|
||||||
return { title: t("modelSelector.title") };
|
return { title: t("modelSelector.title") };
|
||||||
}
|
}
|
||||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openProviderSettings}
|
onPress={openProviderSettings}
|
||||||
@@ -763,17 +792,12 @@ export function CombinedModelSelector({
|
|||||||
})}
|
})}
|
||||||
testID={`selector-header-settings-${view.providerId}`}
|
testID={`selector-header-settings-${view.providerId}`}
|
||||||
>
|
>
|
||||||
<Settings
|
<HeaderSettingsIcon disabled={!serverId} />
|
||||||
size={theme.iconSize.sm}
|
|
||||||
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
title: view.providerLabel,
|
title: view.providerLabel,
|
||||||
leading: ProviderIconForView ? (
|
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
|
||||||
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
|
||||||
) : undefined,
|
|
||||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||||
actions: headerActions,
|
actions: headerActions,
|
||||||
search: {
|
search: {
|
||||||
@@ -789,15 +813,10 @@ export function CombinedModelSelector({
|
|||||||
singleProviderView,
|
singleProviderView,
|
||||||
serverId,
|
serverId,
|
||||||
openProviderSettings,
|
openProviderSettings,
|
||||||
theme.colors.border,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
handleBackToAll,
|
handleBackToAll,
|
||||||
handleSearchQueryChange,
|
handleSearchQueryChange,
|
||||||
searchResetKey,
|
searchResetKey,
|
||||||
t,
|
t,
|
||||||
theme.iconSize.md,
|
|
||||||
theme.iconSize.sm,
|
|
||||||
theme.colors.foreground,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -835,8 +854,8 @@ export function CombinedModelSelector({
|
|||||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{ProviderIcon ? (
|
{hasSelectedProvider ? (
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
|
||||||
) : null}
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
@@ -850,8 +869,8 @@ export function CombinedModelSelector({
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement={desktopPlacement}
|
||||||
desktopMinWidth={360}
|
desktopMinWidth={desktopMinWidth}
|
||||||
desktopFixedHeight={desktopFixedHeight}
|
desktopFixedHeight={desktopFixedHeight}
|
||||||
header={sheetHeader}
|
header={sheetHeader}
|
||||||
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
||||||
@@ -872,7 +891,7 @@ export function CombinedModelSelector({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<View style={styles.sheetLoadingState}>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||||
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1044,4 +1063,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
providerIconMuted: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
providerIconForeground: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function ContextWindowMeter({
|
|||||||
(nextOpen: boolean) => {
|
(nextOpen: boolean) => {
|
||||||
setIsTooltipOpen(nextOpen);
|
setIsTooltipOpen(nextOpen);
|
||||||
if (nextOpen) {
|
if (nextOpen) {
|
||||||
void refreshProviderUsage();
|
void refreshProviderUsage().catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshProviderUsage],
|
[refreshProviderUsage],
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import { SvgXml } from "react-native-svg";
|
import { SvgXml } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
@@ -55,8 +55,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
|
|||||||
{ value: "size" },
|
{ value: "size" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const INDENT_PER_LEVEL = 16;
|
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`;
|
return `${size} B`;
|
||||||
@@ -129,7 +127,7 @@ function TreeRowItem({
|
|||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.entryRow,
|
styles.entryRow,
|
||||||
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
|
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
||||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||||
],
|
],
|
||||||
[depth, isSelected, theme.spacing],
|
[depth, isSelected, theme.spacing],
|
||||||
@@ -143,11 +141,6 @@ function TreeRowItem({
|
|||||||
onDownloadEntry(entry);
|
onDownloadEntry(entry);
|
||||||
}, [onDownloadEntry, entry]);
|
}, [onDownloadEntry, entry]);
|
||||||
|
|
||||||
const chevronStyle = useMemo(
|
|
||||||
() => [styles.chevron, isExpanded && styles.chevronExpanded],
|
|
||||||
[isExpanded],
|
|
||||||
);
|
|
||||||
|
|
||||||
const copyLeading = useMemo(
|
const copyLeading = useMemo(
|
||||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
@@ -159,7 +152,7 @@ function TreeRowItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||||
{depth > 0 && Array.from({ length: depth }, (_, i) => <IndentGuide key={i} index={i} />)}
|
<TreeIndentGuides depth={depth} />
|
||||||
<View style={styles.entryInfo}>
|
<View style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -167,11 +160,7 @@ function TreeRowItem({
|
|||||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||||
}
|
}
|
||||||
if (loading) return <ActivityIndicator size="small" />;
|
if (loading) return <ActivityIndicator size="small" />;
|
||||||
return (
|
return <TreeChevron expanded={isExpanded} />;
|
||||||
<View style={chevronStyle}>
|
|
||||||
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.entryName} numberOfLines={1}>
|
<Text style={styles.entryName} numberOfLines={1}>
|
||||||
@@ -1149,13 +1138,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
indentGuide: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 1,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
entryInfo: {
|
entryInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1163,16 +1145,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
chevron: {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
chevronExpanded: {
|
|
||||||
transform: [{ rotate: "90deg" }],
|
|
||||||
},
|
|
||||||
entryIcon: {
|
entryIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
@@ -1302,16 +1274,3 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
|
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
|
||||||
|
|
||||||
interface IndentGuideProps {
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function IndentGuide({ index }: IndentGuideProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const guideStyle = useMemo(
|
|
||||||
() => [styles.indentGuide, { left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 }],
|
|
||||||
[index, theme.spacing],
|
|
||||||
);
|
|
||||||
return <View style={guideStyle} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||||
|
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
|
||||||
|
|
||||||
export function getHostPickerLabel(
|
export function getHostPickerLabel(
|
||||||
hosts: Array<{ label: string; serverId: string }>,
|
hosts: Array<{ label: string; serverId: string }>,
|
||||||
|
|||||||
@@ -11,10 +11,16 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
|
|||||||
import {
|
import {
|
||||||
ADD_HOST_OPTION_ID,
|
ADD_HOST_OPTION_ID,
|
||||||
ALL_HOSTS_OPTION_ID,
|
ALL_HOSTS_OPTION_ID,
|
||||||
|
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||||
getHostPickerLabel,
|
getHostPickerLabel,
|
||||||
} from "./host-picker-constants";
|
} from "./host-picker-constants";
|
||||||
|
|
||||||
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
export {
|
||||||
|
ADD_HOST_OPTION_ID,
|
||||||
|
ALL_HOSTS_OPTION_ID,
|
||||||
|
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||||
|
getHostPickerLabel,
|
||||||
|
};
|
||||||
|
|
||||||
const SEARCHABLE_THRESHOLD = 10;
|
const SEARCHABLE_THRESHOLD = 10;
|
||||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||||
@@ -115,6 +121,12 @@ export function HostPickerOption({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
|
||||||
|
add: "Add host",
|
||||||
|
all: "All hosts",
|
||||||
|
enableBuiltInDaemon: "Enable built-in daemon",
|
||||||
|
};
|
||||||
|
|
||||||
function SystemHostPickerOption({
|
function SystemHostPickerOption({
|
||||||
active,
|
active,
|
||||||
selected,
|
selected,
|
||||||
@@ -125,12 +137,12 @@ function SystemHostPickerOption({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
kind: "add" | "all";
|
kind: "add" | "all" | "enableBuiltInDaemon";
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const Icon = kind === "add" ? Plus : Server;
|
const Icon = kind === "add" ? Plus : Server;
|
||||||
const label = kind === "add" ? "Add host" : "All hosts";
|
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
|
||||||
const leadingSlot = useMemo(
|
const leadingSlot = useMemo(
|
||||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||||
@@ -158,11 +170,13 @@ export interface HostPickerProps {
|
|||||||
includeAllHost?: boolean;
|
includeAllHost?: boolean;
|
||||||
includeAddHost?: boolean;
|
includeAddHost?: boolean;
|
||||||
onAddHost?: () => void;
|
onAddHost?: () => void;
|
||||||
|
includeEnableBuiltInDaemon?: boolean;
|
||||||
|
onEnableBuiltInDaemon?: () => void;
|
||||||
showActiveConnection?: boolean;
|
showActiveConnection?: boolean;
|
||||||
onOpenHostSettings?: (serverId: string) => void;
|
onOpenHostSettings?: (serverId: string) => void;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
||||||
desktopMinWidth?: number;
|
desktopMinWidth?: number;
|
||||||
addHostTestID?: string;
|
addHostTestID?: string;
|
||||||
hostOptionTestID?: (serverId: string) => string;
|
hostOptionTestID?: (serverId: string) => string;
|
||||||
@@ -179,11 +193,13 @@ export function HostPicker({
|
|||||||
includeAllHost,
|
includeAllHost,
|
||||||
includeAddHost,
|
includeAddHost,
|
||||||
onAddHost,
|
onAddHost,
|
||||||
|
includeEnableBuiltInDaemon,
|
||||||
|
onEnableBuiltInDaemon,
|
||||||
showActiveConnection,
|
showActiveConnection,
|
||||||
onOpenHostSettings,
|
onOpenHostSettings,
|
||||||
searchable,
|
searchable,
|
||||||
title,
|
title,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "bottom-start",
|
||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
addHostTestID,
|
addHostTestID,
|
||||||
hostOptionTestID,
|
hostOptionTestID,
|
||||||
@@ -199,8 +215,13 @@ export function HostPicker({
|
|||||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||||
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
||||||
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
||||||
|
if (includeEnableBuiltInDaemon)
|
||||||
|
hostOptions.push({
|
||||||
|
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||||
|
label: "Enable built-in daemon",
|
||||||
|
});
|
||||||
return hostOptions;
|
return hostOptions;
|
||||||
}, [orderedHosts, includeAllHost, includeAddHost]);
|
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
|
||||||
|
|
||||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||||
|
|
||||||
@@ -208,12 +229,14 @@ export function HostPicker({
|
|||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (id === ADD_HOST_OPTION_ID) {
|
if (id === ADD_HOST_OPTION_ID) {
|
||||||
onAddHost?.();
|
onAddHost?.();
|
||||||
|
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||||
|
onEnableBuiltInDaemon?.();
|
||||||
} else {
|
} else {
|
||||||
onSelect(id);
|
onSelect(id);
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
[onAddHost, onOpenChange, onSelect],
|
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenHostSettings = useCallback(
|
const handleOpenHostSettings = useCallback(
|
||||||
@@ -246,6 +269,11 @@ export function HostPicker({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||||
|
return (
|
||||||
|
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<HostPickerOption
|
<HostPickerOption
|
||||||
serverId={option.id}
|
serverId={option.id}
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ function SidebarHostPicker({
|
|||||||
showActiveConnection
|
showActiveConnection
|
||||||
onOpenHostSettings={onOpenHostSettings}
|
onOpenHostSettings={onOpenHostSettings}
|
||||||
searchable
|
searchable
|
||||||
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={240}
|
desktopMinWidth={240}
|
||||||
addHostTestID="sidebar-host-add"
|
addHostTestID="sidebar-host-add"
|
||||||
hostOptionTestID={sidebarHostOptionTestID}
|
hostOptionTestID={sidebarHostOptionTestID}
|
||||||
|
|||||||
@@ -3038,6 +3038,8 @@ interface ToolCallProps {
|
|||||||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||||
onOpenFilePath?: (filePath: string) => void;
|
onOpenFilePath?: (filePath: string) => void;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
forceInline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
@@ -3054,11 +3056,14 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
onInlineDetailsHoverChange,
|
onInlineDetailsHoverChange,
|
||||||
onInlineDetailsExpandedChange,
|
onInlineDetailsExpandedChange,
|
||||||
onOpenFilePath,
|
onOpenFilePath,
|
||||||
|
defaultExpanded,
|
||||||
|
forceInline = false,
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { openToolCall } = useToolCallSheet();
|
const { openToolCall } = useToolCallSheet();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
|
||||||
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const shouldRenderInline = !isMobile || forceInline;
|
||||||
|
|
||||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
@@ -3096,7 +3101,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
}, [presentation.openFilePath, onOpenFilePath]);
|
}, [presentation.openFilePath, onOpenFilePath]);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (isMobile) {
|
if (!shouldRenderInline) {
|
||||||
openToolCall({
|
openToolCall({
|
||||||
displayName: presentation.displayName,
|
displayName: presentation.displayName,
|
||||||
summary: presentation.summary,
|
summary: presentation.summary,
|
||||||
@@ -3109,7 +3114,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
setIsExpanded((prev) => !prev);
|
setIsExpanded((prev) => !prev);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isMobile,
|
shouldRenderInline,
|
||||||
openToolCall,
|
openToolCall,
|
||||||
presentation.displayName,
|
presentation.displayName,
|
||||||
presentation.summary,
|
presentation.summary,
|
||||||
@@ -3120,22 +3125,22 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onInlineDetailsHoverChange(false);
|
onInlineDetailsHoverChange(false);
|
||||||
}, [isExpanded, isMobile, onInlineDetailsHoverChange]);
|
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsExpandedChange) {
|
if (!onInlineDetailsExpandedChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isMobile) {
|
if (!shouldRenderInline) {
|
||||||
onInlineDetailsExpandedChange(false);
|
onInlineDetailsExpandedChange(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onInlineDetailsExpandedChange(isExpanded);
|
onInlineDetailsExpandedChange(isExpanded);
|
||||||
}, [isExpanded, isMobile, onInlineDetailsExpandedChange]);
|
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onInlineDetailsExpandedChange) {
|
if (!onInlineDetailsExpandedChange) {
|
||||||
@@ -3148,7 +3153,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
|
|
||||||
// Render inline details for desktop
|
// Render inline details for desktop
|
||||||
const renderDetails = useCallback(() => {
|
const renderDetails = useCallback(() => {
|
||||||
if (isMobile) return null;
|
if (!shouldRenderInline) return null;
|
||||||
return (
|
return (
|
||||||
<ToolCallDetailsContent
|
<ToolCallDetailsContent
|
||||||
detail={effectiveDetail}
|
detail={effectiveDetail}
|
||||||
@@ -3157,7 +3162,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||||
|
|
||||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||||
return (
|
return (
|
||||||
@@ -3175,10 +3180,10 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
label={presentation.displayName}
|
label={presentation.displayName}
|
||||||
secondaryLabel={presentation.summary}
|
secondaryLabel={presentation.summary}
|
||||||
icon={presentation.icon}
|
icon={presentation.icon}
|
||||||
isExpanded={!isMobile && isExpanded}
|
isExpanded={shouldRenderInline && isExpanded}
|
||||||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||||||
onOpenFile={handleOpenFile}
|
onOpenFile={handleOpenFile}
|
||||||
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
|
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
|
||||||
isLoading={status === "running" || status === "executing"}
|
isLoading={status === "running" || status === "executing"}
|
||||||
isError={status === "failed"}
|
isError={status === "failed"}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
@@ -3200,5 +3205,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
|||||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||||
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
||||||
|
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||||||
|
if (previous.forceInline !== next.forceInline) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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,366 +1,140 @@
|
|||||||
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import type { PressableStateCallbackType } from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
|
||||||
import {
|
|
||||||
describeCron,
|
|
||||||
everyMsToParts,
|
|
||||||
type IntervalUnit,
|
|
||||||
partsToEveryMs,
|
|
||||||
validateCron,
|
|
||||||
} from "@/utils/schedule-format";
|
|
||||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||||
|
import type { FieldControlSize } from "@/components/ui/control-geometry";
|
||||||
|
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||||
|
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
|
||||||
|
import {
|
||||||
|
CADENCE_PRESET_OPTIONS,
|
||||||
|
normalizeScheduleFormCadence,
|
||||||
|
resolveCronPresetDisplay,
|
||||||
|
resolveCronPresetId,
|
||||||
|
} from "@/schedules/schedule-cadence-options";
|
||||||
|
import { getDeviceTimeZone } from "@/utils/device-timezone";
|
||||||
|
import { describeCron, validateCron } from "@/utils/schedule-format";
|
||||||
|
|
||||||
type CadenceMode = ScheduleCadence["type"];
|
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||||
|
|
||||||
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 {
|
export interface CadenceEditorProps {
|
||||||
value: ScheduleCadence;
|
value: ScheduleCadence;
|
||||||
onChange: (next: ScheduleCadence) => void;
|
onChange: (next: ScheduleCadence) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
size?: FieldControlSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
|
||||||
const mode = value.type;
|
id: option.id,
|
||||||
|
value: option.id,
|
||||||
|
label: option.label,
|
||||||
|
testID: `schedule-cadence-preset-${option.id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
|
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
|
||||||
// once from the incoming cadence via lazy state initializers and bump
|
if (error || !expression) {
|
||||||
// resetKey only when we change the content ourselves (mode switch, preset
|
return null;
|
||||||
// chip) — never on every keystroke.
|
}
|
||||||
const [intervalValueText, setIntervalValueText] = useState(() =>
|
return describeCron({ type: "cron", expression, timezone }) ?? expression;
|
||||||
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
|
}
|
||||||
);
|
|
||||||
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
|
function buildCronCadence(expression: string, timezone: string): CronCadence {
|
||||||
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
|
return { type: "cron", expression, timezone };
|
||||||
);
|
}
|
||||||
const [cronText, setCronText] = useState(() =>
|
|
||||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
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 [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
|
||||||
|
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
|
||||||
// Remember the cron expression the user had so toggling Interval -> Cron and
|
const trimmedCron = cronText.trim();
|
||||||
// back does not discard a blanked-out field. Interval mode rebuilds straight
|
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
|
||||||
// from the live numeric value + unit, so it needs no equivalent ref.
|
const effectiveError = error ?? localCronError;
|
||||||
const lastCronExpression = useRef(
|
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
|
||||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
const currentCadence = useMemo<CronCadence>(
|
||||||
|
() => buildCronCadence(trimmedCron, cronTimeZone),
|
||||||
|
[cronTimeZone, trimmedCron],
|
||||||
);
|
);
|
||||||
|
const selectedPresetId = resolveCronPresetId(currentCadence);
|
||||||
|
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
|
||||||
|
|
||||||
const parsedIntervalValue = useMemo(() => {
|
const handlePresetChange = useCallback(
|
||||||
const parsed = Number.parseInt(intervalValueText, 10);
|
(presetId: string) => {
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
|
||||||
}, [intervalValueText]);
|
if (!preset) {
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
if (nextMode === "every") {
|
setCronText(preset.expression);
|
||||||
emitInterval(parsedIntervalValue, intervalUnit);
|
bumpFieldResetKey();
|
||||||
} else {
|
onChange(buildCronCadence(preset.expression, cronTimeZone));
|
||||||
emitCron(cronText.trim() || lastCronExpression.current);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
|
[cronTimeZone, onChange],
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
const handleCronChange = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
setCronText(text);
|
setCronText(text);
|
||||||
emitCron(text.trim());
|
onChange(buildCronCadence(text.trim(), cronTimeZone));
|
||||||
},
|
},
|
||||||
[emitCron],
|
[cronTimeZone, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePresetPress = useCallback(
|
let feedback: ReactNode = null;
|
||||||
(expression: string) => {
|
if (effectiveError) {
|
||||||
setCronText(expression);
|
feedback = <Text style={styles.error}>{effectiveError}</Text>;
|
||||||
bumpFieldResetKey();
|
} else if (preview) {
|
||||||
emitCron(expression);
|
feedback = <Text style={styles.preview}>{preview}</Text>;
|
||||||
},
|
|
||||||
[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 (
|
return (
|
||||||
<View style={styles.container}>
|
<Field label="Cadence">
|
||||||
<SegmentedControl
|
<View style={styles.stack}>
|
||||||
size="sm"
|
<SelectField
|
||||||
value={mode}
|
label="Cadence"
|
||||||
onValueChange={handleModeChange}
|
value={selectedPresetId === "custom" ? null : selectedPresetId}
|
||||||
options={MODE_OPTIONS}
|
selectedDisplay={selectedPresetDisplay}
|
||||||
style={styles.modeControl}
|
options={PRESET_OPTIONS}
|
||||||
testID="cadence-mode"
|
onChange={handlePresetChange}
|
||||||
/>
|
placeholder="Select cadence"
|
||||||
|
emptyText="No cadences found"
|
||||||
|
searchable={false}
|
||||||
|
title="Cadence"
|
||||||
|
size={size}
|
||||||
|
triggerTestID="schedule-cadence-preset-trigger"
|
||||||
|
field={false}
|
||||||
|
/>
|
||||||
|
|
||||||
{mode === "every" ? (
|
<FormTextInput
|
||||||
<View style={styles.section}>
|
size={size}
|
||||||
<View style={styles.intervalRow}>
|
testID="cadence-cron-expression"
|
||||||
<AdaptiveTextInput
|
accessibilityLabel="Cron expression"
|
||||||
testID="cadence-interval-value"
|
initialValue={cronText}
|
||||||
accessibilityLabel="Interval value"
|
resetKey={`cadence-cron-${fieldResetKey}`}
|
||||||
initialValue={intervalValueText}
|
value={cronText}
|
||||||
resetKey={`cadence-interval-${fieldResetKey}`}
|
onChangeText={handleCronChange}
|
||||||
value={intervalValueText}
|
placeholder="0 9 * * *"
|
||||||
onChangeText={handleIntervalValueChange}
|
autoCapitalize="none"
|
||||||
keyboardType="number-pad"
|
autoCorrect={false}
|
||||||
style={styles.intervalInput}
|
spellCheck={false}
|
||||||
/>
|
style={styles.cronInput}
|
||||||
<SegmentedControl
|
/>
|
||||||
size="sm"
|
{feedback}
|
||||||
value={intervalUnit}
|
</View>
|
||||||
onValueChange={handleUnitChange}
|
</Field>
|
||||||
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
stack: {
|
||||||
gap: theme.spacing[3],
|
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: {
|
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,
|
fontFamily: theme.fontFamily.mono,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
hint: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
error: {
|
error: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.palette.red[300],
|
color: theme.colors.palette.red[300],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { ChevronRight } from "lucide-react-native";
|
||||||
|
import { SPACING, type Theme } from "@/styles/theme";
|
||||||
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
|
||||||
|
// Shared presentation primitives for the app's directory trees. Both the Files
|
||||||
|
// explorer (server-loaded listings) and the Changes view (client-built from diff
|
||||||
|
// paths) render different data, but their ROWS should look identical — same
|
||||||
|
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||||
|
// drift apart.
|
||||||
|
export const TREE_INDENT_PER_LEVEL = 16;
|
||||||
|
|
||||||
|
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||||
|
* in the Changes tree so their indentation can't drift apart. */
|
||||||
|
export function treeRowPaddingLeft(depth: number): number {
|
||||||
|
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical guide lines connecting nested rows to their ancestors — one line per
|
||||||
|
* ancestor depth level, positioned absolutely within the (relative) row. Renders
|
||||||
|
* nothing at depth 0.
|
||||||
|
*/
|
||||||
|
export function TreeIndentGuides({ depth }: { depth: number }) {
|
||||||
|
const guides = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: depth }, (_, index) => ({
|
||||||
|
key: index,
|
||||||
|
style: [
|
||||||
|
styles.indentGuide,
|
||||||
|
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
[depth],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{guides.map((guide) => (
|
||||||
|
<View key={guide.key} style={guide.style} pointerEvents="none" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||||
|
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={expanded ? CHEVRON_EXPANDED_STYLE : styles.chevron}>
|
||||||
|
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: Theme) => ({
|
||||||
|
indentGuide: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 1,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
chevron: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chevronExpanded: {
|
||||||
|
transform: [{ rotate: "90deg" }],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stable module-level style ref so TreeChevron passes a constant array, not one created
|
||||||
|
// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo.
|
||||||
|
const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded];
|
||||||
@@ -16,10 +16,16 @@ import type {
|
|||||||
TextStyle,
|
TextStyle,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
buttonIconSize,
|
||||||
|
createControlGeometry,
|
||||||
|
type ButtonControlSize,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||||
type ButtonSize = "xs" | "sm" | "md" | "lg";
|
type ButtonSize = ButtonControlSize;
|
||||||
|
|
||||||
type LeftIcon =
|
type LeftIcon =
|
||||||
| ReactElement
|
| ReactElement
|
||||||
@@ -27,85 +33,130 @@ type LeftIcon =
|
|||||||
| ((color: string) => ReactElement)
|
| ((color: string) => ReactElement)
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
const ICON_SIZE: Record<ButtonSize, number> = { xs: 12, sm: 14, md: 16, lg: 20 };
|
interface ButtonIconProps {
|
||||||
|
loading: boolean;
|
||||||
|
leftIcon?: LeftIcon;
|
||||||
|
iconSize: number;
|
||||||
|
iconColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) {
|
||||||
base: {
|
if (loading) {
|
||||||
flexDirection: "row",
|
return (
|
||||||
alignItems: "center",
|
<View>
|
||||||
justifyContent: "center",
|
<ActivityIndicator size="small" color={iconColor} />
|
||||||
gap: theme.spacing[2],
|
</View>
|
||||||
borderRadius: theme.borderRadius.lg,
|
);
|
||||||
borderWidth: 1,
|
}
|
||||||
borderColor: "transparent",
|
|
||||||
},
|
if (!leftIcon) return null;
|
||||||
md: {
|
|
||||||
paddingVertical: theme.spacing[3],
|
if (typeof leftIcon === "object" && "type" in leftIcon) {
|
||||||
paddingHorizontal: theme.spacing[4],
|
return <View>{leftIcon}</View>;
|
||||||
},
|
}
|
||||||
xs: {
|
|
||||||
minHeight: 28,
|
if (
|
||||||
paddingVertical: theme.spacing[1],
|
typeof leftIcon === "function" &&
|
||||||
paddingHorizontal: theme.spacing[3],
|
!leftIcon.prototype?.isReactComponent &&
|
||||||
borderRadius: theme.borderRadius.md,
|
leftIcon.length > 0
|
||||||
},
|
) {
|
||||||
sm: {
|
return <View>{(leftIcon as (color: string) => ReactElement)(iconColor)}</View>;
|
||||||
paddingVertical: theme.spacing[1.5],
|
}
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
||||||
},
|
return (
|
||||||
lg: {
|
<View>
|
||||||
paddingVertical: theme.spacing[4],
|
<Icon color={iconColor} size={iconSize} />
|
||||||
paddingHorizontal: theme.spacing[6],
|
</View>
|
||||||
borderRadius: theme.borderRadius.xl,
|
);
|
||||||
},
|
}
|
||||||
default: {
|
|
||||||
backgroundColor: theme.colors.accent,
|
const ThemedButtonIcon = withUnistyles(ButtonIcon);
|
||||||
borderColor: theme.colors.accent,
|
|
||||||
},
|
const foregroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
||||||
secondary: {
|
const foregroundMutedIconMapping = (theme: Theme) => ({
|
||||||
backgroundColor: theme.colors.surface3,
|
iconColor: theme.colors.foregroundMuted,
|
||||||
borderColor: theme.colors.surface3,
|
});
|
||||||
},
|
const accentForegroundIconMapping = (theme: Theme) => ({
|
||||||
outline: {
|
iconColor: theme.colors.accentForeground,
|
||||||
backgroundColor: "transparent",
|
});
|
||||||
borderColor: theme.colors.borderAccent,
|
const destructiveForegroundIconMapping = (theme: Theme) => ({
|
||||||
},
|
iconColor: theme.colors.destructiveForeground,
|
||||||
ghost: {
|
});
|
||||||
backgroundColor: "transparent",
|
|
||||||
borderColor: "transparent",
|
const styles = StyleSheet.create((theme) => {
|
||||||
},
|
const geometry = createControlGeometry(theme);
|
||||||
destructive: {
|
|
||||||
backgroundColor: theme.colors.destructive,
|
return {
|
||||||
borderColor: theme.colors.destructive,
|
base: {
|
||||||
},
|
flexDirection: "row",
|
||||||
pressed: {
|
alignItems: "center",
|
||||||
opacity: 0.85,
|
justifyContent: "center",
|
||||||
},
|
gap: theme.spacing[2],
|
||||||
disabled: {
|
borderRadius: theme.borderRadius.lg,
|
||||||
opacity: theme.opacity[50],
|
borderWidth: 1,
|
||||||
},
|
borderColor: "transparent",
|
||||||
text: {
|
},
|
||||||
color: theme.colors.foreground,
|
md: {
|
||||||
fontSize: theme.fontSize.sm,
|
...geometry.buttonMd,
|
||||||
fontWeight: theme.fontWeight.normal,
|
},
|
||||||
},
|
xs: {
|
||||||
textXs: {
|
...geometry.buttonXs,
|
||||||
fontSize: theme.fontSize.xs,
|
},
|
||||||
},
|
sm: {
|
||||||
textDefault: {
|
...geometry.buttonSm,
|
||||||
color: theme.colors.accentForeground,
|
},
|
||||||
},
|
lg: {
|
||||||
textDestructive: {
|
...geometry.buttonLg,
|
||||||
color: theme.colors.destructiveForeground,
|
},
|
||||||
},
|
default: {
|
||||||
textGhost: {
|
backgroundColor: theme.colors.accent,
|
||||||
color: theme.colors.foregroundMuted,
|
borderColor: theme.colors.accent,
|
||||||
},
|
},
|
||||||
textGhostHovered: {
|
secondary: {
|
||||||
color: theme.colors.foreground,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
borderColor: theme.colors.surface3,
|
||||||
}));
|
},
|
||||||
|
outline: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
backgroundColor: theme.colors.destructive,
|
||||||
|
borderColor: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
...geometry.buttonText,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
textXs: {
|
||||||
|
...geometry.buttonTextXs,
|
||||||
|
},
|
||||||
|
textDefault: {
|
||||||
|
color: theme.colors.accentForeground,
|
||||||
|
},
|
||||||
|
textDestructive: {
|
||||||
|
color: theme.colors.destructiveForeground,
|
||||||
|
},
|
||||||
|
textGhost: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
textGhostHovered: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
children,
|
children,
|
||||||
@@ -118,6 +169,7 @@ export function Button({
|
|||||||
disabled,
|
disabled,
|
||||||
loading = false,
|
loading = false,
|
||||||
accessibilityRole,
|
accessibilityRole,
|
||||||
|
accessibilityState: accessibilityStateProp,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
Omit<PressableProps, "style"> & {
|
Omit<PressableProps, "style"> & {
|
||||||
@@ -131,7 +183,6 @@ export function Button({
|
|||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const isDisabled = disabled || loading;
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
let variantStyle: ViewStyle;
|
let variantStyle: ViewStyle;
|
||||||
@@ -188,55 +239,21 @@ export function Button({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => ({ disabled: isDisabled, busy: loading }),
|
() => ({ ...accessibilityStateProp, disabled: isDisabled, busy: loading }),
|
||||||
[isDisabled, loading],
|
[accessibilityStateProp, isDisabled, loading],
|
||||||
);
|
);
|
||||||
|
|
||||||
function resolveIconColor(): string {
|
function resolveIconMapping() {
|
||||||
if (variant === "default") {
|
if (variant === "default") {
|
||||||
return theme.colors.accentForeground;
|
return accentForegroundIconMapping;
|
||||||
|
}
|
||||||
|
if (variant === "destructive") {
|
||||||
|
return destructiveForegroundIconMapping;
|
||||||
}
|
}
|
||||||
if (variant === "ghost") {
|
if (variant === "ghost") {
|
||||||
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
return isGhostHovered ? foregroundIconMapping : foregroundMutedIconMapping;
|
||||||
}
|
}
|
||||||
return theme.colors.foreground;
|
return foregroundIconMapping;
|
||||||
}
|
|
||||||
|
|
||||||
function renderIcon() {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<ActivityIndicator size="small" color={resolveIconColor()} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!leftIcon) return null;
|
|
||||||
|
|
||||||
// Pre-rendered element — pass through
|
|
||||||
if (typeof leftIcon === "object" && "type" in leftIcon) {
|
|
||||||
return <View>{leftIcon}</View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const color = resolveIconColor();
|
|
||||||
const iconSize = ICON_SIZE[size];
|
|
||||||
|
|
||||||
// Render function
|
|
||||||
if (
|
|
||||||
typeof leftIcon === "function" &&
|
|
||||||
!leftIcon.prototype?.isReactComponent &&
|
|
||||||
leftIcon.length > 0
|
|
||||||
) {
|
|
||||||
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component type
|
|
||||||
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<Icon color={color} size={iconSize} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +266,12 @@ export function Button({
|
|||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
{renderIcon()}
|
<ThemedButtonIcon
|
||||||
|
loading={loading}
|
||||||
|
leftIcon={leftIcon}
|
||||||
|
iconSize={buttonIconSize[size]}
|
||||||
|
uniProps={resolveIconMapping()}
|
||||||
|
/>
|
||||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||||
{trailing}
|
{trailing}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ViewStyle } from "react-native";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||||
|
|
||||||
|
function buildWidthStyle(input: {
|
||||||
|
desktopMinWidth?: number;
|
||||||
|
referenceWidth: number | null;
|
||||||
|
}): Pick<ViewStyle, "width" | "minWidth" | "maxWidth"> {
|
||||||
|
const [frameStyle] = buildDesktopFrameStyle({
|
||||||
|
desktopMinWidth: input.desktopMinWidth,
|
||||||
|
referenceWidth: input.referenceWidth,
|
||||||
|
desktopFixedHeight: undefined,
|
||||||
|
desktopPositionStyle: { left: 0, top: 0 },
|
||||||
|
shouldHideDesktopContent: false,
|
||||||
|
availableHeight: undefined,
|
||||||
|
}) as ViewStyle[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: frameStyle.width,
|
||||||
|
minWidth: frameStyle.minWidth,
|
||||||
|
maxWidth: frameStyle.maxWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildDesktopFrameStyle", () => {
|
||||||
|
it("lets a narrow trigger grow to the default desktop ceiling", () => {
|
||||||
|
expect(buildWidthStyle({ referenceWidth: 120 })).toEqual({
|
||||||
|
width: undefined,
|
||||||
|
minWidth: 120,
|
||||||
|
maxWidth: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a wide trigger from being capped below its own width", () => {
|
||||||
|
expect(buildWidthStyle({ referenceWidth: 470 })).toEqual({
|
||||||
|
width: undefined,
|
||||||
|
minWidth: 470,
|
||||||
|
maxWidth: 470,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses desktopMinWidth as an explicit floor raiser", () => {
|
||||||
|
expect(buildWidthStyle({ desktopMinWidth: 360, referenceWidth: 120 })).toEqual({
|
||||||
|
width: undefined,
|
||||||
|
minWidth: 360,
|
||||||
|
maxWidth: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the trigger as the floor when it is wider than desktopMinWidth", () => {
|
||||||
|
expect(buildWidthStyle({ desktopMinWidth: 240, referenceWidth: 300 })).toEqual({
|
||||||
|
width: undefined,
|
||||||
|
minWidth: 300,
|
||||||
|
maxWidth: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export interface DesktopFrameStyleInput {
|
||||||
|
desktopMinWidth: number | undefined;
|
||||||
|
referenceWidth: number | null;
|
||||||
|
desktopFixedHeight: number | undefined;
|
||||||
|
desktopPositionStyle: StyleProp<ViewStyle>;
|
||||||
|
shouldHideDesktopContent: boolean;
|
||||||
|
availableHeight: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp<ViewStyle> {
|
||||||
|
const {
|
||||||
|
desktopMinWidth,
|
||||||
|
referenceWidth,
|
||||||
|
desktopFixedHeight,
|
||||||
|
desktopPositionStyle,
|
||||||
|
shouldHideDesktopContent,
|
||||||
|
availableHeight,
|
||||||
|
} = input;
|
||||||
|
const fixedHeightStyle =
|
||||||
|
desktopFixedHeight != null
|
||||||
|
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
||||||
|
: null;
|
||||||
|
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
|
||||||
|
const availableHeightStyle =
|
||||||
|
typeof availableHeight === "number"
|
||||||
|
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
|
||||||
|
: null;
|
||||||
|
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
position: "absolute" as const,
|
||||||
|
minWidth: floor,
|
||||||
|
maxWidth: Math.max(400, floor),
|
||||||
|
},
|
||||||
|
fixedHeightStyle,
|
||||||
|
desktopPositionStyle,
|
||||||
|
hiddenStyle,
|
||||||
|
availableHeightStyle,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { forwardRef, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
View,
|
View,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
|
type TargetedEvent,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -20,6 +29,7 @@ const chevronColorMapping = (theme: Theme) => ({
|
|||||||
interface TriggerState {
|
interface TriggerState {
|
||||||
pressed: boolean;
|
pressed: boolean;
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
|
focused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
|
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
|
||||||
@@ -36,23 +46,45 @@ interface ComboboxTriggerProps extends Omit<PressableProps, "style" | "children"
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
|
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
|
||||||
{ children, chevron, style, block = false, ...props },
|
{ children, chevron, style, block = false, onFocus, onBlur, ...props },
|
||||||
ref,
|
ref,
|
||||||
): ReactElement {
|
): ReactElement {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
if (typeof style === "function") {
|
if (typeof style === "function") {
|
||||||
return style({ pressed, hovered });
|
return style({ pressed, hovered, focused });
|
||||||
}
|
}
|
||||||
return style;
|
return style;
|
||||||
},
|
},
|
||||||
[style],
|
[focused, style],
|
||||||
|
);
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<TargetedEvent>) => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus?.(event);
|
||||||
|
},
|
||||||
|
[onFocus],
|
||||||
|
);
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<TargetedEvent>) => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur?.(event);
|
||||||
|
},
|
||||||
|
[onBlur],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
|
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable ref={ref} collapsable={false} style={pressableStyle} {...props}>
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
collapsable={false}
|
||||||
|
style={pressableStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{children}
|
{children}
|
||||||
{chevron !== null &&
|
{chevron !== null &&
|
||||||
|
|||||||
@@ -61,10 +61,14 @@ import {
|
|||||||
} from "@/components/adaptive-modal-sheet";
|
} from "@/components/adaptive-modal-sheet";
|
||||||
import { FloatingSurface } from "@/components/ui/floating";
|
import { FloatingSurface } from "@/components/ui/floating";
|
||||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||||
|
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||||
|
|
||||||
|
export { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||||
|
|
||||||
const IS_WEB = isWeb;
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export type ComboboxOption = ComboboxOptionModel;
|
export type ComboboxOption = ComboboxOptionModel;
|
||||||
|
export type ComboboxDesktopPlacement = "top-start" | "bottom-start";
|
||||||
|
|
||||||
export interface ComboboxProps {
|
export interface ComboboxProps {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
@@ -99,7 +103,7 @@ export interface ComboboxProps {
|
|||||||
presentation?: "push" | "replace";
|
presentation?: "push" | "replace";
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: ComboboxDesktopPlacement;
|
||||||
/**
|
/**
|
||||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
* coordinates resolve. This intentionally disables fade enter/exit animation
|
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||||
@@ -381,7 +385,7 @@ function OptionsList({
|
|||||||
interface DesktopPositionInput {
|
interface DesktopPositionInput {
|
||||||
isDesktopAboveSearch: boolean;
|
isDesktopAboveSearch: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
desktopPlacement: "top-start" | "bottom-start";
|
desktopPlacement: ComboboxDesktopPlacement;
|
||||||
referenceTop: number | null;
|
referenceTop: number | null;
|
||||||
referenceLeft: number | null;
|
referenceLeft: number | null;
|
||||||
referenceAtOrigin: boolean;
|
referenceAtOrigin: boolean;
|
||||||
@@ -656,7 +660,7 @@ interface DesktopResetSetters {
|
|||||||
function useDesktopPositionReset(
|
function useDesktopPositionReset(
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
desktopPlacement: "top-start" | "bottom-start",
|
desktopPlacement: ComboboxDesktopPlacement,
|
||||||
update: () => unknown,
|
update: () => unknown,
|
||||||
setters: DesktopResetSetters,
|
setters: DesktopResetSetters,
|
||||||
) {
|
) {
|
||||||
@@ -850,46 +854,6 @@ function buildFloatingMiddleware(input: FloatingMiddlewareInput) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopContainerStyleInput {
|
|
||||||
desktopMinWidth: number | undefined;
|
|
||||||
referenceWidth: number | null;
|
|
||||||
desktopFixedHeight: number | undefined;
|
|
||||||
desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"];
|
|
||||||
shouldHideDesktopContent: boolean;
|
|
||||||
availableHeight: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
|
|
||||||
const {
|
|
||||||
desktopMinWidth,
|
|
||||||
referenceWidth,
|
|
||||||
desktopFixedHeight,
|
|
||||||
desktopPositionStyle,
|
|
||||||
shouldHideDesktopContent,
|
|
||||||
availableHeight,
|
|
||||||
} = input;
|
|
||||||
const fixedHeightStyle =
|
|
||||||
desktopFixedHeight != null
|
|
||||||
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
|
||||||
: null;
|
|
||||||
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
|
|
||||||
const availableHeightStyle =
|
|
||||||
typeof availableHeight === "number"
|
|
||||||
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
|
|
||||||
: null;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
position: "absolute" as const,
|
|
||||||
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
|
|
||||||
maxWidth: Math.max(400, desktopMinWidth ?? 0),
|
|
||||||
},
|
|
||||||
fixedHeightStyle,
|
|
||||||
desktopPositionStyle,
|
|
||||||
hiddenStyle,
|
|
||||||
availableHeightStyle,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDesktopKey(key: string): key is DesktopKey {
|
function isDesktopKey(key: string): key is DesktopKey {
|
||||||
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
|
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1219,7 @@ export function Combobox({
|
|||||||
presentation,
|
presentation,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "bottom-start",
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
desktopFixedHeight,
|
desktopFixedHeight,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createControlGeometry,
|
||||||
|
getControlInteractionPhase,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
borderRadius: {
|
||||||
|
md: 6,
|
||||||
|
lg: 8,
|
||||||
|
xl: 12,
|
||||||
|
},
|
||||||
|
borderWidth: {
|
||||||
|
1: 1,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
accent: "#20744A",
|
||||||
|
borderAccent: "#2F3534",
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: 12,
|
||||||
|
sm: 14,
|
||||||
|
base: 16,
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
50: 0.5,
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
0: 0,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
6: 24,
|
||||||
|
},
|
||||||
|
} as unknown as Theme;
|
||||||
|
|
||||||
|
describe("control geometry", () => {
|
||||||
|
it("keeps resting control borders transparent while preserving border geometry", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(geometry.controlRest).toMatchObject({
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "transparent",
|
||||||
|
outlineColor: "transparent",
|
||||||
|
outlineWidth: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shared hover border and active focus ring values", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(geometry.controlHover).toEqual({
|
||||||
|
borderColor: "#2F3534",
|
||||||
|
});
|
||||||
|
expect(geometry.controlActive).toEqual({
|
||||||
|
borderColor: "#2F3534",
|
||||||
|
outlineColor: "#20744A",
|
||||||
|
outlineOffset: 1,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineWidth: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves disabled, focus, open, pressed, and hover into one interaction phase", () => {
|
||||||
|
expect(getControlInteractionPhase({ disabled: true, focused: true })).toBe("rest");
|
||||||
|
expect(getControlInteractionPhase({ focused: true })).toBe("active");
|
||||||
|
expect(getControlInteractionPhase({ open: true })).toBe("active");
|
||||||
|
expect(getControlInteractionPhase({ pressed: true })).toBe("active");
|
||||||
|
expect(getControlInteractionPhase({ hovered: true })).toBe("hover");
|
||||||
|
expect(getControlInteractionPhase({})).toBe("rest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps field text sizing tied to control size", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(geometry.fieldTextSm.fontSize).toBe(14);
|
||||||
|
expect(geometry.fieldTextSm.lineHeight).toBe(20);
|
||||||
|
expect(geometry.fieldTextMd.fontSize).toBe(16);
|
||||||
|
expect(geometry.fieldTextMd.lineHeight).toBe(22);
|
||||||
|
expect(geometry.formTextInputSm.fontSize).toBe(14);
|
||||||
|
expect(geometry.formTextInputSm.lineHeight).toBe(20);
|
||||||
|
expect(geometry.formTextInputMd.fontSize).toBe(16);
|
||||||
|
expect(geometry.formTextInputMd.lineHeight).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives field padding from line height without changing the control height", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
||||||
|
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
|
||||||
|
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
|
||||||
|
geometry.fieldControlSm.minHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
||||||
|
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
|
||||||
|
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
|
||||||
|
geometry.fieldControlMd.minHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
|
||||||
|
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts segmented control inset from the nested segment radius", () => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
||||||
|
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
||||||
|
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
||||||
|
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||||
|
export type FieldControlSize = "sm" | "md";
|
||||||
|
export type SegmentedControlSize = "sm" | "md";
|
||||||
|
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||||
|
|
||||||
|
export interface ControlInteractionState {
|
||||||
|
hovered?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
pressed?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlInteractionStyleMap {
|
||||||
|
controlRest: StyleProp<ViewStyle>;
|
||||||
|
controlHover: StyleProp<ViewStyle>;
|
||||||
|
controlActive: StyleProp<ViewStyle>;
|
||||||
|
controlDisabled?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPACT_CONTROL_HEIGHT = 32;
|
||||||
|
const FIELD_CONTROL_HEIGHT = 44;
|
||||||
|
const SEGMENTED_COMPACT_INSET = 2;
|
||||||
|
const SEGMENTED_FIELD_INSET = 3;
|
||||||
|
const SWITCH_TRACK_WIDTH = 34;
|
||||||
|
const SWITCH_TRACK_HEIGHT = 20;
|
||||||
|
const SWITCH_THUMB_SIZE = 16;
|
||||||
|
const CONTROL_FOCUS_RING_WIDTH = 2;
|
||||||
|
const CONTROL_FOCUS_RING_OFFSET = 1;
|
||||||
|
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
||||||
|
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||||
|
|
||||||
|
const controlHeights = {
|
||||||
|
compact: COMPACT_CONTROL_HEIGHT,
|
||||||
|
field: FIELD_CONTROL_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buttonIconSize: Record<ButtonControlSize, number> = {
|
||||||
|
xs: ICON_SIZE.xs,
|
||||||
|
sm: ICON_SIZE.sm,
|
||||||
|
md: ICON_SIZE.md,
|
||||||
|
lg: ICON_SIZE.lg,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||||
|
sm: ICON_SIZE.sm,
|
||||||
|
md: ICON_SIZE.md,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const switchGeometry = {
|
||||||
|
trackWidth: SWITCH_TRACK_WIDTH,
|
||||||
|
trackHeight: SWITCH_TRACK_HEIGHT,
|
||||||
|
thumbSize: SWITCH_THUMB_SIZE,
|
||||||
|
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
||||||
|
};
|
||||||
|
|
||||||
|
function nestedRadius(containerRadius: number, inset: number): number {
|
||||||
|
return Math.max(0, containerRadius - inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldLineHeight(fontSize: number): number {
|
||||||
|
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
|
||||||
|
return (controlHeight - lineHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getControlInteractionPhase(
|
||||||
|
state: ControlInteractionState,
|
||||||
|
): ControlInteractionPhase {
|
||||||
|
if (state.disabled) {
|
||||||
|
return "rest";
|
||||||
|
}
|
||||||
|
if (state.active || state.focused || state.open || state.pressed) {
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
if (state.hovered) {
|
||||||
|
return "hover";
|
||||||
|
}
|
||||||
|
return "rest";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveControlInteractionStyles(
|
||||||
|
styles: ControlInteractionStyleMap,
|
||||||
|
state: ControlInteractionState,
|
||||||
|
): StyleProp<ViewStyle> {
|
||||||
|
const phase = getControlInteractionPhase(state);
|
||||||
|
return [
|
||||||
|
styles.controlRest,
|
||||||
|
phase === "hover" ? styles.controlHover : null,
|
||||||
|
phase === "active" ? styles.controlActive : null,
|
||||||
|
state.disabled ? styles.controlDisabled : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createControlGeometry(theme: Theme) {
|
||||||
|
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm);
|
||||||
|
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||||
|
const fieldControlSm = {
|
||||||
|
minHeight: controlHeights.compact,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: fieldVerticalPadding(controlHeights.compact, fieldTextSmLineHeight),
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
};
|
||||||
|
const fieldControlMd = {
|
||||||
|
minHeight: controlHeights.field,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: fieldVerticalPadding(controlHeights.field, fieldTextMdLineHeight),
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
};
|
||||||
|
const fieldTextSm = {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: fieldTextSmLineHeight,
|
||||||
|
};
|
||||||
|
const fieldTextMd = {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
lineHeight: fieldTextMdLineHeight,
|
||||||
|
};
|
||||||
|
const segmentedContainerSmRadius = theme.borderRadius.md;
|
||||||
|
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
||||||
|
const switchControl = {
|
||||||
|
minHeight: controlHeights.compact,
|
||||||
|
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||||
|
} satisfies { minHeight: number; justifyContent: "center" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonXs: {
|
||||||
|
minHeight: controlHeights.compact,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
buttonSm: {
|
||||||
|
minHeight: controlHeights.compact,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
buttonMd: {
|
||||||
|
minHeight: controlHeights.field,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
buttonLg: {
|
||||||
|
minHeight: controlHeights.field,
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
buttonTextXs: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
formTextInputSm: {
|
||||||
|
...fieldControlSm,
|
||||||
|
...fieldTextSm,
|
||||||
|
},
|
||||||
|
formTextInputMd: {
|
||||||
|
...fieldControlMd,
|
||||||
|
...fieldTextMd,
|
||||||
|
},
|
||||||
|
formTextInput: {
|
||||||
|
...fieldControlMd,
|
||||||
|
...fieldTextMd,
|
||||||
|
},
|
||||||
|
fieldControlSm,
|
||||||
|
fieldControlMd,
|
||||||
|
fieldTextSm,
|
||||||
|
fieldTextMd,
|
||||||
|
controlRest: {
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "transparent",
|
||||||
|
outlineWidth: 0,
|
||||||
|
outlineColor: "transparent",
|
||||||
|
},
|
||||||
|
controlHover: {
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
controlActive: {
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
outlineColor: theme.colors.accent,
|
||||||
|
outlineOffset: CONTROL_FOCUS_RING_OFFSET,
|
||||||
|
outlineStyle: "solid" as const,
|
||||||
|
outlineWidth: CONTROL_FOCUS_RING_WIDTH,
|
||||||
|
},
|
||||||
|
controlFocusRingColor: {
|
||||||
|
outlineColor: theme.colors.accent,
|
||||||
|
},
|
||||||
|
controlDisabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
switchControl,
|
||||||
|
segmentedContainerSm: {
|
||||||
|
minHeight: controlHeights.compact,
|
||||||
|
padding: SEGMENTED_COMPACT_INSET,
|
||||||
|
borderRadius: segmentedContainerSmRadius,
|
||||||
|
},
|
||||||
|
segmentedContainerMd: {
|
||||||
|
minHeight: controlHeights.field,
|
||||||
|
padding: SEGMENTED_FIELD_INSET,
|
||||||
|
borderRadius: segmentedContainerMdRadius,
|
||||||
|
},
|
||||||
|
segmentedSegmentSm: {
|
||||||
|
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
||||||
|
},
|
||||||
|
segmentedSegmentMd: {
|
||||||
|
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
||||||
|
},
|
||||||
|
segmentedLabelSm: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
segmentedLabelMd: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,30 @@
|
|||||||
import { forwardRef, useMemo, type ReactNode } from "react";
|
import {
|
||||||
import { Text, View, type TextInput } from "react-native";
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ForwardedRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
StyleSheet as RNStyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
type TextInput,
|
||||||
|
type TextStyle,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
|
||||||
|
import {
|
||||||
|
createControlGeometry,
|
||||||
|
resolveControlInteractionStyles,
|
||||||
|
type FieldControlSize,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
|
|
||||||
interface FormFieldProps {
|
interface FieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
@@ -11,20 +32,20 @@ interface FormFieldProps {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
|
export function Field({ label, children, hint, error, testID }: FieldProps) {
|
||||||
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
|
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
|
||||||
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
|
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
|
||||||
const hintOrError = useMemo(() => {
|
const subtext = useMemo(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Text style={styles.errorText} testID={errorTestID}>
|
<Text numberOfLines={1} style={styles.errorText} testID={errorTestID}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hint) {
|
if (hint) {
|
||||||
return (
|
return (
|
||||||
<Text style={styles.hintText} testID={hintTestID}>
|
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
|
||||||
{hint}
|
{hint}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -36,17 +57,167 @@ export function FormField({ label, children, hint, error, testID }: FormFieldPro
|
|||||||
<View style={styles.container} testID={testID}>
|
<View style={styles.container} testID={testID}>
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
{children}
|
{children}
|
||||||
{hintOrError}
|
{subtext}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
|
type FormTextInputProps = AdaptiveTextInputProps & {
|
||||||
{ style, ...props },
|
size?: FieldControlSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatFormTextInputStyle = ViewStyle & TextStyle;
|
||||||
|
|
||||||
|
interface SplitFormTextInputStyle {
|
||||||
|
chromeStyle?: ViewStyle;
|
||||||
|
inputStyle?: TextStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFormTextInputStyle(style: AdaptiveTextInputProps["style"]): SplitFormTextInputStyle {
|
||||||
|
const flattened = RNStyleSheet.flatten(style) as FlatFormTextInputStyle | undefined;
|
||||||
|
if (!flattened) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
color,
|
||||||
|
fontFamily,
|
||||||
|
fontSize,
|
||||||
|
fontStyle,
|
||||||
|
fontVariant,
|
||||||
|
fontWeight,
|
||||||
|
includeFontPadding,
|
||||||
|
letterSpacing,
|
||||||
|
lineHeight,
|
||||||
|
textAlign,
|
||||||
|
textAlignVertical,
|
||||||
|
textDecorationColor,
|
||||||
|
textDecorationLine,
|
||||||
|
textDecorationStyle,
|
||||||
|
textShadowColor,
|
||||||
|
textShadowOffset,
|
||||||
|
textShadowRadius,
|
||||||
|
textTransform,
|
||||||
|
writingDirection,
|
||||||
|
...chromeStyle
|
||||||
|
} = flattened;
|
||||||
|
|
||||||
|
const inputStyle: TextStyle = {
|
||||||
|
color,
|
||||||
|
fontFamily,
|
||||||
|
fontSize,
|
||||||
|
fontStyle,
|
||||||
|
fontVariant,
|
||||||
|
fontWeight,
|
||||||
|
includeFontPadding,
|
||||||
|
letterSpacing,
|
||||||
|
lineHeight,
|
||||||
|
textAlign,
|
||||||
|
textAlignVertical,
|
||||||
|
textDecorationColor,
|
||||||
|
textDecorationLine,
|
||||||
|
textDecorationStyle,
|
||||||
|
textShadowColor,
|
||||||
|
textShadowOffset,
|
||||||
|
textShadowRadius,
|
||||||
|
textTransform,
|
||||||
|
writingDirection,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
chromeStyle: stripUnistylesMetadata(chromeStyle),
|
||||||
|
inputStyle: stripUnistylesMetadata(inputStyle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
|
||||||
|
const cleanStyle: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(style as Record<string, unknown>)) {
|
||||||
|
if (key.startsWith("unistyles_") || value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cleanStyle[key] = value;
|
||||||
|
}
|
||||||
|
return cleanStyle as TStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInput | null): void {
|
||||||
|
if (typeof forwardedRef === "function") {
|
||||||
|
forwardedRef(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (forwardedRef) {
|
||||||
|
forwardedRef.current = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput(
|
||||||
|
{ size = "md", style, onFocus, onBlur, editable, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
|
const [focused, setFocused] = useState(false);
|
||||||
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
|
const isDisabled = editable === false;
|
||||||
|
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
|
||||||
|
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
|
||||||
|
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
|
||||||
|
const setInputRef = useCallback(
|
||||||
|
(node: TextInput | null) => {
|
||||||
|
assignTextInputRef(ref, node);
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
|
||||||
|
(event) => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus?.(event);
|
||||||
|
},
|
||||||
|
[onFocus],
|
||||||
|
);
|
||||||
|
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
|
||||||
|
(event) => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur?.(event);
|
||||||
|
},
|
||||||
|
[onBlur],
|
||||||
|
);
|
||||||
|
const inputStyle = useMemo(
|
||||||
|
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
|
||||||
|
[inputSizeStyle, splitStyle.inputStyle],
|
||||||
|
) as AdaptiveTextInputProps["style"];
|
||||||
|
const chromeStyle = useCallback(
|
||||||
|
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
formInputStyles.chrome,
|
||||||
|
chromeSizeStyle,
|
||||||
|
resolveControlInteractionStyles(
|
||||||
|
{
|
||||||
|
controlRest: formInputStyles.controlRest,
|
||||||
|
controlHover: formInputStyles.controlHover,
|
||||||
|
controlActive: formInputStyles.controlActive,
|
||||||
|
controlDisabled: formInputStyles.controlDisabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hovered,
|
||||||
|
focused,
|
||||||
|
disabled: isDisabled,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
splitStyle.chromeStyle,
|
||||||
|
],
|
||||||
|
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable disabled={isDisabled} style={chromeStyle}>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
ref={setInputRef}
|
||||||
|
editable={editable}
|
||||||
|
{...props}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -56,27 +227,59 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
label: {
|
label: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
hintText: {
|
hintText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: Math.round(theme.fontSize.xs * 1.4),
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: theme.colors.palette.red[300],
|
color: theme.colors.palette.red[300],
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: Math.round(theme.fontSize.xs * 1.4),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const formInputStyles = StyleSheet.create((theme) => ({
|
const formInputStyles = StyleSheet.create((theme) => {
|
||||||
input: {
|
const geometry = createControlGeometry(theme);
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
return {
|
||||||
paddingHorizontal: theme.spacing[4],
|
chrome: {
|
||||||
paddingVertical: theme.spacing[3],
|
backgroundColor: theme.colors.surface2,
|
||||||
color: theme.colors.foreground,
|
},
|
||||||
borderWidth: 1,
|
chromeSm: {
|
||||||
borderColor: theme.colors.border,
|
...geometry.fieldControlSm,
|
||||||
fontSize: theme.fontSize.base,
|
},
|
||||||
},
|
chromeMd: {
|
||||||
}));
|
...geometry.fieldControlMd,
|
||||||
|
},
|
||||||
|
controlRest: {
|
||||||
|
...geometry.controlRest,
|
||||||
|
},
|
||||||
|
controlHover: {
|
||||||
|
...geometry.controlHover,
|
||||||
|
},
|
||||||
|
controlActive: {
|
||||||
|
...geometry.controlActive,
|
||||||
|
},
|
||||||
|
controlDisabled: {
|
||||||
|
...geometry.controlDisabled,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingVertical: 0,
|
||||||
|
outlineColor: "transparent",
|
||||||
|
outlineWidth: 0,
|
||||||
|
},
|
||||||
|
inputSm: {
|
||||||
|
...geometry.fieldTextSm,
|
||||||
|
},
|
||||||
|
inputMd: {
|
||||||
|
...geometry.fieldTextMd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
type SegmentedControlSize = "sm" | "md";
|
createControlGeometry,
|
||||||
|
segmentedIconSize,
|
||||||
|
type SegmentedControlSize,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
|
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
|
||||||
|
|
||||||
@@ -25,6 +29,21 @@ interface SegmentedControlProps<T extends string> {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SegmentIconProps {
|
||||||
|
icon: SegmentedControlIconRenderer;
|
||||||
|
iconSize: number;
|
||||||
|
iconColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
||||||
|
return <View style={styles.iconContainer}>{icon({ color: iconColor, size: iconSize })}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
||||||
|
|
||||||
|
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
||||||
|
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -34,11 +53,10 @@ export function SegmentedControl<T extends string>({
|
|||||||
style,
|
style,
|
||||||
testID,
|
testID,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||||
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
|
const iconSize = segmentedIconSize[size];
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => [styles.container, containerSizeStyle, style],
|
() => [styles.container, containerSizeStyle, style],
|
||||||
@@ -49,14 +67,12 @@ export function SegmentedControl<T extends string>({
|
|||||||
<View style={containerStyle} testID={testID}>
|
<View style={containerStyle} testID={testID}>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = option.value === value;
|
const isSelected = option.value === value;
|
||||||
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SegmentItem
|
<SegmentItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
option={option}
|
option={option}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
iconColor={iconColor}
|
|
||||||
iconSize={iconSize}
|
iconSize={iconSize}
|
||||||
hideLabels={hideLabels}
|
hideLabels={hideLabels}
|
||||||
segmentSizeStyle={segmentSizeStyle}
|
segmentSizeStyle={segmentSizeStyle}
|
||||||
@@ -73,7 +89,6 @@ export function SegmentedControl<T extends string>({
|
|||||||
function SegmentItem<T extends string>({
|
function SegmentItem<T extends string>({
|
||||||
option,
|
option,
|
||||||
isSelected,
|
isSelected,
|
||||||
iconColor,
|
|
||||||
iconSize,
|
iconSize,
|
||||||
hideLabels,
|
hideLabels,
|
||||||
segmentSizeStyle,
|
segmentSizeStyle,
|
||||||
@@ -83,7 +98,6 @@ function SegmentItem<T extends string>({
|
|||||||
}: {
|
}: {
|
||||||
option: SegmentedControlOption<T>;
|
option: SegmentedControlOption<T>;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
iconColor: string;
|
|
||||||
iconSize: number;
|
iconSize: number;
|
||||||
hideLabels: boolean;
|
hideLabels: boolean;
|
||||||
segmentSizeStyle: StyleProp<ViewStyle>;
|
segmentSizeStyle: StyleProp<ViewStyle>;
|
||||||
@@ -119,15 +133,18 @@ function SegmentItem<T extends string>({
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
|
aria-selected={isSelected}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
testID={option.testID}
|
testID={option.testID}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
{option.icon ? (
|
{option.icon ? (
|
||||||
<View style={styles.iconContainer}>
|
<ThemedSegmentIcon
|
||||||
{option.icon({ color: iconColor, size: iconSize })}
|
icon={option.icon}
|
||||||
</View>
|
iconSize={iconSize}
|
||||||
|
uniProps={isSelected ? selectedIconMapping : mutedIconMapping}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{hideLabels ? null : (
|
{hideLabels ? null : (
|
||||||
<Text style={labelStyle} numberOfLines={1}>
|
<Text style={labelStyle} numberOfLines={1}>
|
||||||
@@ -138,68 +155,68 @@ function SegmentItem<T extends string>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => {
|
||||||
container: {
|
const geometry = createControlGeometry(theme);
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "stretch",
|
return {
|
||||||
backgroundColor: theme.colors.surface2,
|
container: {
|
||||||
borderRadius: theme.borderRadius.lg,
|
flexDirection: "row",
|
||||||
gap: 2,
|
alignItems: "stretch",
|
||||||
},
|
backgroundColor: theme.colors.surface2,
|
||||||
containerSm: {
|
gap: 2,
|
||||||
padding: 2,
|
},
|
||||||
},
|
containerSm: {
|
||||||
containerMd: {
|
...geometry.segmentedContainerSm,
|
||||||
padding: 3,
|
},
|
||||||
},
|
containerMd: {
|
||||||
segment: {
|
...geometry.segmentedContainerMd,
|
||||||
flexDirection: "row",
|
},
|
||||||
alignItems: "center",
|
segment: {
|
||||||
justifyContent: "center",
|
flexDirection: "row",
|
||||||
flexShrink: 0,
|
alignItems: "center",
|
||||||
borderRadius: theme.borderRadius.lg,
|
justifyContent: "center",
|
||||||
gap: theme.spacing[1],
|
flexShrink: 0,
|
||||||
},
|
gap: theme.spacing[1],
|
||||||
segmentSm: {
|
},
|
||||||
paddingVertical: theme.spacing[1.5],
|
segmentSm: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
...geometry.segmentedSegmentSm,
|
||||||
},
|
},
|
||||||
segmentMd: {
|
segmentMd: {
|
||||||
paddingVertical: theme.spacing[3],
|
...geometry.segmentedSegmentMd,
|
||||||
paddingHorizontal: theme.spacing[6],
|
},
|
||||||
},
|
segmentSelected: {
|
||||||
segmentSelected: {
|
backgroundColor: theme.colors.surface0,
|
||||||
backgroundColor: theme.colors.surface0,
|
shadowColor: "#000",
|
||||||
shadowColor: "#000",
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOpacity: 0.08,
|
||||||
shadowOpacity: 0.08,
|
shadowRadius: 2,
|
||||||
shadowRadius: 2,
|
elevation: 1,
|
||||||
elevation: 1,
|
},
|
||||||
},
|
segmentHover: {
|
||||||
segmentHover: {
|
backgroundColor: theme.colors.surface1,
|
||||||
backgroundColor: theme.colors.surface1,
|
},
|
||||||
},
|
segmentPressed: {
|
||||||
segmentPressed: {
|
backgroundColor: theme.colors.surface1,
|
||||||
backgroundColor: theme.colors.surface1,
|
},
|
||||||
},
|
segmentDisabled: {
|
||||||
segmentDisabled: {
|
opacity: theme.opacity[50],
|
||||||
opacity: theme.opacity[50],
|
},
|
||||||
},
|
iconContainer: {
|
||||||
iconContainer: {
|
alignItems: "center",
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
},
|
||||||
},
|
label: {
|
||||||
label: {
|
color: theme.colors.foregroundMuted,
|
||||||
color: theme.colors.foregroundMuted,
|
fontWeight: theme.fontWeight.normal,
|
||||||
fontWeight: theme.fontWeight.normal,
|
},
|
||||||
},
|
labelSm: {
|
||||||
labelSm: {
|
...geometry.segmentedLabelSm,
|
||||||
fontSize: theme.fontSize.sm,
|
},
|
||||||
},
|
labelMd: {
|
||||||
labelMd: {
|
...geometry.segmentedLabelMd,
|
||||||
fontSize: theme.fontSize.base,
|
},
|
||||||
},
|
labelSelected: {
|
||||||
labelSelected: {
|
color: theme.colors.foreground,
|
||||||
color: theme.colors.foreground,
|
},
|
||||||
},
|
};
|
||||||
}));
|
});
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
type TargetedEvent,
|
||||||
|
} from "react-native";
|
||||||
|
import { ChevronDown } from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
import {
|
||||||
|
createControlGeometry,
|
||||||
|
resolveControlInteractionStyles,
|
||||||
|
type FieldControlSize,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
|
import { Field } from "@/components/ui/form-field";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
export interface SelectFieldDisplay {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldOption<TValue> {
|
||||||
|
id: string;
|
||||||
|
value: TValue;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
kind?: ComboboxOption["kind"];
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldRenderOptionInput<TValue> {
|
||||||
|
option: SelectFieldOption<TValue>;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps<TValue> {
|
||||||
|
label: string;
|
||||||
|
value: TValue | null;
|
||||||
|
selectedDisplay: SelectFieldDisplay | null;
|
||||||
|
options: SelectFieldOption<TValue>[];
|
||||||
|
onChange: (value: TValue, display: SelectFieldDisplay) => void;
|
||||||
|
placeholder: string;
|
||||||
|
emptyText: string;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
error?: string | null;
|
||||||
|
searchable?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
title?: string;
|
||||||
|
size?: FieldControlSize;
|
||||||
|
getValueKey?: (value: TValue) => string;
|
||||||
|
renderOption?: (input: SelectFieldRenderOptionInput<TValue>) => ReactElement;
|
||||||
|
triggerLeading?: ReactNode;
|
||||||
|
field?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
triggerTestID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldTriggerProps {
|
||||||
|
display?: SelectFieldDisplay | null;
|
||||||
|
label?: string;
|
||||||
|
isPlaceholder?: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
hovered?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
leading?: ReactNode;
|
||||||
|
size?: FieldControlSize;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
|
||||||
|
const foregroundMutedMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSelectedOptionId<TValue>(
|
||||||
|
options: readonly SelectFieldOption<TValue>[],
|
||||||
|
value: TValue | null,
|
||||||
|
getValueKey: ((value: TValue) => string) | undefined,
|
||||||
|
): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (getValueKey) {
|
||||||
|
const selectedKey = getValueKey(value);
|
||||||
|
return options.find((option) => getValueKey(option.value) === selectedKey)?.id ?? "";
|
||||||
|
}
|
||||||
|
return options.find((option) => Object.is(option.value, value))?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useVisibleSelectOptions<TValue>(
|
||||||
|
options: SelectFieldOption<TValue>[],
|
||||||
|
loading: boolean,
|
||||||
|
): SelectFieldOption<TValue>[] {
|
||||||
|
const previousOptionsRef = useRef<SelectFieldOption<TValue>[]>(options);
|
||||||
|
if (options.length > 0 || !loading) {
|
||||||
|
previousOptionsRef.current = options;
|
||||||
|
}
|
||||||
|
if (loading && options.length === 0) {
|
||||||
|
return previousOptionsRef.current;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectFieldTrigger({
|
||||||
|
display,
|
||||||
|
label: explicitLabel,
|
||||||
|
isPlaceholder: explicitIsPlaceholder,
|
||||||
|
placeholder,
|
||||||
|
hovered = false,
|
||||||
|
focused = false,
|
||||||
|
active = false,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
leading,
|
||||||
|
size = "md",
|
||||||
|
testID,
|
||||||
|
}: SelectFieldTriggerProps): ReactElement {
|
||||||
|
const sizeStyle = size === "sm" ? styles.triggerSm : styles.triggerMd;
|
||||||
|
const textSizeStyle = size === "sm" ? styles.triggerTextSm : styles.triggerTextMd;
|
||||||
|
const triggerStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.trigger,
|
||||||
|
sizeStyle,
|
||||||
|
resolveControlInteractionStyles(
|
||||||
|
{
|
||||||
|
controlRest: styles.controlRest,
|
||||||
|
controlHover: styles.controlHover,
|
||||||
|
controlActive: styles.controlActive,
|
||||||
|
controlDisabled: styles.controlDisabled,
|
||||||
|
},
|
||||||
|
{ hovered, focused, active, disabled },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[active, disabled, focused, hovered, sizeStyle],
|
||||||
|
);
|
||||||
|
const label = explicitLabel ?? display?.label ?? placeholder;
|
||||||
|
const isPlaceholder = explicitIsPlaceholder ?? display == null;
|
||||||
|
const textStyle = useMemo(
|
||||||
|
() => [isPlaceholder ? styles.placeholderText : styles.triggerText, textSizeStyle],
|
||||||
|
[isPlaceholder, textSizeStyle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View pointerEvents="none" style={triggerStyle} testID={testID}>
|
||||||
|
{leading}
|
||||||
|
<Text style={textStyle} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.spinnerSlot}>
|
||||||
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<ThemedChevronDown size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField<TValue>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
selectedDisplay,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
emptyText,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
searchable = false,
|
||||||
|
searchPlaceholder,
|
||||||
|
title,
|
||||||
|
size = "md",
|
||||||
|
getValueKey,
|
||||||
|
renderOption,
|
||||||
|
triggerLeading,
|
||||||
|
field = true,
|
||||||
|
testID,
|
||||||
|
triggerTestID,
|
||||||
|
}: SelectFieldProps<TValue>): ReactElement {
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [triggerFocused, setTriggerFocused] = useState(false);
|
||||||
|
const visibleOptions = useVisibleSelectOptions(options, loading);
|
||||||
|
const selectedOptionId = useMemo(
|
||||||
|
() => getSelectedOptionId(visibleOptions, value, getValueKey),
|
||||||
|
[getValueKey, value, visibleOptions],
|
||||||
|
);
|
||||||
|
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||||
|
() =>
|
||||||
|
visibleOptions.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
kind: option.kind,
|
||||||
|
})),
|
||||||
|
[visibleOptions],
|
||||||
|
);
|
||||||
|
const optionById = useMemo(
|
||||||
|
() => new Map(visibleOptions.map((option) => [option.id, option])),
|
||||||
|
[visibleOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const option = optionById.get(id);
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(option.value, { label: option.label, description: option.description });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[onChange, optionById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen((current) => !current);
|
||||||
|
}, [disabled]);
|
||||||
|
const handleTriggerFocus = useCallback((_event: NativeSyntheticEvent<TargetedEvent>) => {
|
||||||
|
setTriggerFocused(true);
|
||||||
|
}, []);
|
||||||
|
const handleTriggerBlur = useCallback((_event: NativeSyntheticEvent<TargetedEvent>) => {
|
||||||
|
setTriggerFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderComboboxOption = useCallback(
|
||||||
|
({
|
||||||
|
option,
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
option: ComboboxOption;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => {
|
||||||
|
const selectOption = optionById.get(option.id);
|
||||||
|
if (!selectOption) {
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label={option.label}
|
||||||
|
description={option.description}
|
||||||
|
kind={option.kind}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (renderOption) {
|
||||||
|
return renderOption({ option: selectOption, selected, active, onPress });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
testID={selectOption.testID}
|
||||||
|
label={selectOption.label}
|
||||||
|
description={selectOption.description}
|
||||||
|
kind={selectOption.kind}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[optionById, renderOption],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayLabel = selectedDisplay?.label ?? placeholder;
|
||||||
|
const fieldHint = selectedDisplay?.description ?? hint;
|
||||||
|
|
||||||
|
const control = (
|
||||||
|
<>
|
||||||
|
<View ref={anchorRef} collapsable={false}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleTriggerFocus}
|
||||||
|
onBlur={handleTriggerBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${label} (${displayLabel})`}
|
||||||
|
testID={triggerTestID}
|
||||||
|
>
|
||||||
|
{({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||||
|
<SelectFieldTrigger
|
||||||
|
display={selectedDisplay}
|
||||||
|
placeholder={placeholder}
|
||||||
|
hovered={Boolean(hovered)}
|
||||||
|
focused={triggerFocused}
|
||||||
|
active={pressed || open}
|
||||||
|
disabled={disabled}
|
||||||
|
loading={loading}
|
||||||
|
leading={triggerLeading}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Combobox
|
||||||
|
options={comboboxOptions}
|
||||||
|
value={selectedOptionId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
searchable={searchable}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
emptyText={loading && visibleOptions.length === 0 ? "Loading..." : emptyText}
|
||||||
|
title={title ?? label}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
renderOption={renderComboboxOption}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
return <View testID={testID}>{control}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label={label} hint={fieldHint} error={error} testID={testID}>
|
||||||
|
{control}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => {
|
||||||
|
const geometry = createControlGeometry(theme);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
triggerSm: {
|
||||||
|
...geometry.fieldControlSm,
|
||||||
|
},
|
||||||
|
triggerMd: {
|
||||||
|
...geometry.fieldControlMd,
|
||||||
|
},
|
||||||
|
controlRest: {
|
||||||
|
...geometry.controlRest,
|
||||||
|
},
|
||||||
|
controlHover: {
|
||||||
|
...geometry.controlHover,
|
||||||
|
},
|
||||||
|
controlActive: {
|
||||||
|
...geometry.controlActive,
|
||||||
|
},
|
||||||
|
controlDisabled: {
|
||||||
|
...geometry.controlDisabled,
|
||||||
|
},
|
||||||
|
triggerText: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
triggerTextSm: {
|
||||||
|
...geometry.fieldTextSm,
|
||||||
|
},
|
||||||
|
triggerTextMd: {
|
||||||
|
...geometry.fieldTextMd,
|
||||||
|
},
|
||||||
|
spinnerSlot: {
|
||||||
|
flexShrink: 0,
|
||||||
|
width: ICON_SIZE.md,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -7,10 +7,15 @@ import { Switch } from "./switch";
|
|||||||
const { theme } = vi.hoisted(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
opacity: { 50: 0.5 },
|
opacity: { 50: 0.5 },
|
||||||
|
spacing: { 0: 0, 3: 12, 4: 16, 6: 24 },
|
||||||
|
fontSize: { xs: 12, sm: 14, base: 16 },
|
||||||
|
borderRadius: { md: 6, lg: 8, xl: 12 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
colors: {
|
colors: {
|
||||||
surface3: "#333",
|
surface3: "#333",
|
||||||
accent: "#0a84ff",
|
accent: "#0a84ff",
|
||||||
accentForeground: "#fff",
|
accentForeground: "#fff",
|
||||||
|
borderAccent: "#555",
|
||||||
palette: { white: "#fff" },
|
palette: { white: "#fff" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -32,6 +37,9 @@ vi.mock("react-native-reanimated", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
vi.mock("react-native", () => ({
|
||||||
|
Platform: {
|
||||||
|
select: (values: Record<string, unknown>) => values.web ?? values.default,
|
||||||
|
},
|
||||||
Pressable: ({
|
Pressable: ({
|
||||||
"aria-checked": ariaChecked,
|
"aria-checked": ariaChecked,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
@@ -71,7 +79,13 @@ vi.mock("react-native-unistyles", () => ({
|
|||||||
StyleSheet: {
|
StyleSheet: {
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
},
|
},
|
||||||
useUnistyles: () => ({ theme }),
|
withUnistyles:
|
||||||
|
(
|
||||||
|
Component: React.ComponentType<Record<string, unknown>>,
|
||||||
|
mapping?: (theme: unknown) => object,
|
||||||
|
) =>
|
||||||
|
(props: Record<string, unknown>) =>
|
||||||
|
React.createElement(Component, { ...props, ...mapping?.(theme) }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Switch", () => {
|
describe("Switch", () => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import Animated, {
|
|||||||
useDerivedValue,
|
useDerivedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { createControlGeometry, switchGeometry } from "@/components/ui/control-geometry";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
interface SwitchProps {
|
interface SwitchProps {
|
||||||
value: boolean;
|
value: boolean;
|
||||||
@@ -23,11 +25,54 @@ interface SwitchProps {
|
|||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRACK = { width: 34, height: 20 };
|
|
||||||
const THUMB = 16;
|
|
||||||
|
|
||||||
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
|
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
|
||||||
|
|
||||||
|
interface SwitchTrackProps {
|
||||||
|
value: boolean;
|
||||||
|
trackOffColor: string;
|
||||||
|
trackOnColor: string;
|
||||||
|
thumbOffColor: string;
|
||||||
|
thumbOnColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchTrack({
|
||||||
|
value,
|
||||||
|
trackOffColor,
|
||||||
|
trackOnColor,
|
||||||
|
thumbOffColor,
|
||||||
|
thumbOnColor,
|
||||||
|
}: SwitchTrackProps) {
|
||||||
|
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
|
||||||
|
|
||||||
|
const trackAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(progress.value, [0, 1], [trackOffColor, trackOnColor]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(progress.value, [0, 1], [thumbOffColor, thumbOnColor]),
|
||||||
|
transform: [{ translateX: progress.value * switchGeometry.thumbTravel }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const trackStyle = useMemo(() => [styles.switchTrack, trackAnimatedStyle], [trackAnimatedStyle]);
|
||||||
|
const thumbStyle = useMemo(
|
||||||
|
() => [styles.switchThumb, styles.thumb, thumbAnimatedStyle],
|
||||||
|
[thumbAnimatedStyle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={trackStyle}>
|
||||||
|
<Animated.View style={thumbStyle} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedSwitchTrack = withUnistyles(SwitchTrack, (theme: Theme) => ({
|
||||||
|
trackOffColor: theme.colors.surface3,
|
||||||
|
trackOnColor: theme.colors.accent,
|
||||||
|
thumbOffColor: theme.colors.palette.white,
|
||||||
|
thumbOnColor: theme.colors.accentForeground,
|
||||||
|
}));
|
||||||
|
|
||||||
export function Switch({
|
export function Switch({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
@@ -36,31 +81,6 @@ export function Switch({
|
|||||||
testID,
|
testID,
|
||||||
style,
|
style,
|
||||||
}: SwitchProps) {
|
}: SwitchProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const track = TRACK;
|
|
||||||
const thumb = THUMB;
|
|
||||||
const padding = (track.height - thumb) / 2;
|
|
||||||
const thumbTravel = track.width - thumb - padding * 2;
|
|
||||||
|
|
||||||
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
|
|
||||||
|
|
||||||
const trackAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
backgroundColor: interpolateColor(
|
|
||||||
progress.value,
|
|
||||||
[0, 1],
|
|
||||||
[theme.colors.surface3, theme.colors.accent],
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
backgroundColor: interpolateColor(
|
|
||||||
progress.value,
|
|
||||||
[0, 1],
|
|
||||||
[theme.colors.palette.white, theme.colors.accentForeground],
|
|
||||||
),
|
|
||||||
transform: [{ translateX: progress.value * thumbTravel }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -72,30 +92,9 @@ export function Switch({
|
|||||||
|
|
||||||
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
|
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
|
||||||
const pressableStyle = useMemo(
|
const pressableStyle = useMemo(
|
||||||
() => [disabled ? styles.disabled : null, style],
|
() => [styles.switchControl, disabled ? styles.disabled : null, style],
|
||||||
[disabled, style],
|
[disabled, style],
|
||||||
);
|
);
|
||||||
const trackStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.track,
|
|
||||||
{
|
|
||||||
width: track.width,
|
|
||||||
height: track.height,
|
|
||||||
borderRadius: track.height / 2,
|
|
||||||
padding,
|
|
||||||
},
|
|
||||||
trackAnimatedStyle,
|
|
||||||
],
|
|
||||||
[track.width, track.height, padding, trackAnimatedStyle],
|
|
||||||
);
|
|
||||||
const thumbStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.thumb,
|
|
||||||
{ width: thumb, height: thumb, borderRadius: thumb / 2 },
|
|
||||||
thumbAnimatedStyle,
|
|
||||||
],
|
|
||||||
[thumb, thumbAnimatedStyle],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -109,25 +108,39 @@ export function Switch({
|
|||||||
testID={testID}
|
testID={testID}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
<Animated.View style={trackStyle}>
|
<ThemedSwitchTrack value={value} />
|
||||||
<Animated.View style={thumbStyle} />
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => {
|
||||||
track: {
|
const geometry = createControlGeometry(theme);
|
||||||
justifyContent: "center",
|
|
||||||
},
|
return {
|
||||||
thumb: {
|
switchControl: {
|
||||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
...geometry.switchControl,
|
||||||
shadowOffset: { width: 0, height: 1 },
|
},
|
||||||
shadowRadius: 2,
|
switchTrack: {
|
||||||
shadowOpacity: 1,
|
width: switchGeometry.trackWidth,
|
||||||
elevation: 2,
|
height: switchGeometry.trackHeight,
|
||||||
},
|
borderRadius: switchGeometry.trackHeight / 2,
|
||||||
disabled: {
|
padding: (switchGeometry.trackHeight - switchGeometry.thumbSize) / 2,
|
||||||
opacity: theme.opacity[50],
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
}));
|
switchThumb: {
|
||||||
|
width: switchGeometry.thumbSize,
|
||||||
|
height: switchGeometry.thumbSize,
|
||||||
|
borderRadius: switchGeometry.thumbSize / 2,
|
||||||
|
},
|
||||||
|
thumb: {
|
||||||
|
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOpacity: 1,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -805,6 +805,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
onRetryProvider={onRetryModelProvider}
|
onRetryProvider={onRetryModelProvider}
|
||||||
isRetryingProvider={isRetryingModelProvider}
|
isRetryingProvider={isRetryingModelProvider}
|
||||||
serverId={modelSelectorServerId}
|
serverId={modelSelectorServerId}
|
||||||
|
desktopPlacement="top-start"
|
||||||
|
desktopMinWidth={360}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -1012,6 +1014,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
isRetryingProvider={isRetryingModelProvider}
|
isRetryingProvider={isRetryingModelProvider}
|
||||||
renderTrigger={renderModelTrigger}
|
renderTrigger={renderModelTrigger}
|
||||||
serverId={modelSelectorServerId}
|
serverId={modelSelectorServerId}
|
||||||
|
desktopPlacement="top-start"
|
||||||
|
desktopMinWidth={360}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1684,6 +1688,8 @@ export function DraftAgentControls({
|
|||||||
onRetryProvider={onRetryModelProvider}
|
onRetryProvider={onRetryModelProvider}
|
||||||
isRetryingProvider={isRetryingModelProvider}
|
isRetryingProvider={isRetryingModelProvider}
|
||||||
serverId={modelSelectorServerId}
|
serverId={modelSelectorServerId}
|
||||||
|
desktopPlacement="top-start"
|
||||||
|
desktopMinWidth={360}
|
||||||
/>
|
/>
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<ControlledAgentControls
|
<ControlledAgentControls
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
|
|||||||
tag: attachment.attachment.tag,
|
tag: attachment.attachment.tag,
|
||||||
text: attachment.attachment.text,
|
text: attachment.attachment.text,
|
||||||
html: attachment.attachment.outerHTML,
|
html: attachment.attachment.outerHTML,
|
||||||
intent: attachment.attachment.intent ?? null,
|
|
||||||
comment: attachment.attachment.comment ?? null,
|
comment: attachment.attachment.comment ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
onVolumeLevel: (level) => {
|
onVolumeLevel: (level) => {
|
||||||
runtime?.handleCaptureVolume(level);
|
runtime?.handleCaptureVolume(level);
|
||||||
},
|
},
|
||||||
|
onInterruption: () => {
|
||||||
|
void runtime?.stopVoice().catch((error) => {
|
||||||
|
console.error("[VoiceEngine] Failed to stop after audio interruption:", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("[VoiceEngine] Capture error:", error);
|
console.error("[VoiceEngine] Capture error:", error);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Auggie CLI",
|
title: "Auggie CLI",
|
||||||
description:
|
description:
|
||||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||||
version: "0.31.0",
|
version: "0.32.0",
|
||||||
iconId: "auggie",
|
iconId: "auggie",
|
||||||
installLink: "https://www.augmentcode.com/",
|
installLink: "https://www.augmentcode.com/",
|
||||||
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
|
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
|
||||||
env: {
|
env: {
|
||||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||||
},
|
},
|
||||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
|||||||
title: "Cline",
|
title: "Cline",
|
||||||
description:
|
description:
|
||||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
version: "3.0.34",
|
version: "3.0.38",
|
||||||
iconId: "cline",
|
iconId: "cline",
|
||||||
installLink: "https://cline.bot/cli",
|
installLink: "https://cline.bot/cli",
|
||||||
command: ["npx", "-y", "cline@3.0.34", "--acp"],
|
command: ["npx", "-y", "cline@3.0.38", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
title: "Codebuddy Code",
|
title: "Codebuddy Code",
|
||||||
description: "Tencent Cloud's official intelligent coding tool",
|
description: "Tencent Cloud's official intelligent coding tool",
|
||||||
version: "2.114.0",
|
version: "2.117.2",
|
||||||
iconId: "codebuddy-code",
|
iconId: "codebuddy-code",
|
||||||
installLink: "https://www.codebuddy.cn/cli/",
|
installLink: "https://www.codebuddy.cn/cli/",
|
||||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
|
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.117.2", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codewhale",
|
id: "codewhale",
|
||||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
|||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
description: "A coding agent that puts leading models at your command.",
|
description: "A coding agent that puts leading models at your command.",
|
||||||
version: "0.2.12",
|
version: "0.2.22",
|
||||||
iconId: "dimcode",
|
iconId: "dimcode",
|
||||||
installLink: "https://dimcode.dev/docs/acp.html",
|
installLink: "https://dimcode.dev/docs/acp.html",
|
||||||
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
|
command: ["npx", "-y", "dimcode@0.2.22", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dirac",
|
id: "dirac",
|
||||||
title: "Dirac",
|
title: "Dirac",
|
||||||
description:
|
description:
|
||||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||||
version: "0.4.12",
|
version: "0.4.13",
|
||||||
iconId: "dirac",
|
iconId: "dirac",
|
||||||
installLink: "https://dirac.run",
|
installLink: "https://dirac.run",
|
||||||
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
|
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
title: "Factory Droid",
|
title: "Factory Droid",
|
||||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
version: "0.161.0",
|
version: "0.164.1",
|
||||||
iconId: "factory-droid",
|
iconId: "factory-droid",
|
||||||
installLink: "https://factory.ai/product/cli",
|
installLink: "https://factory.ai/product/cli",
|
||||||
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
|
command: ["npx", "-y", "droid@0.164.1", "exec", "--output-format", "acp-daemon"],
|
||||||
env: {
|
env: {
|
||||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.8.1",
|
version: "0.9.2",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.9.2", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
|||||||
id: "nova",
|
id: "nova",
|
||||||
title: "Nova",
|
title: "Nova",
|
||||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||||
version: "1.1.22",
|
version: "1.1.25",
|
||||||
iconId: "nova",
|
iconId: "nova",
|
||||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||||
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
|
command: ["npx", "-y", "@compass-ai/nova@1.1.25", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "poolside",
|
id: "poolside",
|
||||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.0.33",
|
version: "1.0.39",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.39", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
title: "Qwen Code",
|
title: "Qwen Code",
|
||||||
description: "Alibaba's Qwen coding assistant",
|
description: "Alibaba's Qwen coding assistant",
|
||||||
version: "0.19.3",
|
version: "0.19.7",
|
||||||
iconId: "qwen-code",
|
iconId: "qwen-code",
|
||||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
|
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.7", "--acp", "--experimental-skills"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sigit",
|
id: "sigit",
|
||||||
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
|
|||||||
description: "ByteDance's official TRAE coding agent with native ACP support",
|
description: "ByteDance's official TRAE coding agent with native ACP support",
|
||||||
version: "manual",
|
version: "manual",
|
||||||
iconId: "traecli",
|
iconId: "traecli",
|
||||||
installLink: "https://docs.trae.cn/cli",
|
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
|
||||||
command: ["traecli", "acp", "serve"],
|
command: ["traecli", "acp", "serve"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function daemonConfigQueryKey(serverId: string | null) {
|
||||||
|
return ["daemon-config", serverId] as const;
|
||||||
|
}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
providersSnapshotQueryKey,
|
providersSnapshotQueryKey,
|
||||||
providersSnapshotQueryRoot,
|
providersSnapshotQueryRoot,
|
||||||
providersSnapshotRequestOptions,
|
providersSnapshotRequestOptions,
|
||||||
} from "./providers-snapshot-query";
|
} from "@/data/providers-snapshot";
|
||||||
|
|
||||||
describe("providers snapshot query scope", () => {
|
describe("providers snapshot query scope", () => {
|
||||||
it("normalizes blank cwd values to the home scope", () => {
|
it("normalizes blank cwd values to the home scope", () => {
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||||
|
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
||||||
|
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||||
|
import { providersSnapshotQueryKey } from "@/data/providers-snapshot";
|
||||||
|
import {
|
||||||
|
checkoutDiffPushRoute,
|
||||||
|
invalidateServerDataQueriesAfterReconnect,
|
||||||
|
mountServerDataPushRouter,
|
||||||
|
workspaceTerminalsPushRoute,
|
||||||
|
} from "@/data/push-router";
|
||||||
|
|
||||||
|
type ProvidersSnapshotUpdateMessage = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "providers_snapshot_update" }
|
||||||
|
>;
|
||||||
|
type CheckoutDiffUpdateMessage = Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>;
|
||||||
|
type SubscribeCheckoutDiffResponseMessage = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "subscribe_checkout_diff_response" }
|
||||||
|
>;
|
||||||
|
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||||
|
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||||
|
type RouterMessage =
|
||||||
|
| ProvidersSnapshotUpdateMessage
|
||||||
|
| CheckoutDiffUpdateMessage
|
||||||
|
| SubscribeCheckoutDiffResponseMessage
|
||||||
|
| StatusMessage
|
||||||
|
| TerminalsChangedMessage;
|
||||||
|
type RouterMessageType = RouterMessage["type"];
|
||||||
|
type RouterHandler = (message: RouterMessage) => void;
|
||||||
|
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
|
||||||
|
|
||||||
|
const daemonConfig: MutableDaemonConfig = {
|
||||||
|
mcp: { injectIntoAgents: true },
|
||||||
|
browserTools: { enabled: false },
|
||||||
|
providers: {},
|
||||||
|
metadataGeneration: { providers: [] },
|
||||||
|
autoArchiveAfterMerge: false,
|
||||||
|
enableTerminalAgentHooks: false,
|
||||||
|
appendSystemPrompt: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}): {
|
||||||
|
client: RouterClient;
|
||||||
|
emit: <K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>) => void;
|
||||||
|
subscribeCheckoutDiffCalls: Array<{
|
||||||
|
cwd: string;
|
||||||
|
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||||
|
subscriptionId: string;
|
||||||
|
}>;
|
||||||
|
unsubscribeCheckoutDiffCalls: string[];
|
||||||
|
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||||
|
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||||
|
} {
|
||||||
|
const handlers: Record<RouterMessageType, RouterHandler[]> = {
|
||||||
|
providers_snapshot_update: [],
|
||||||
|
checkout_diff_update: [],
|
||||||
|
subscribe_checkout_diff_response: [],
|
||||||
|
status: [],
|
||||||
|
terminals_changed: [],
|
||||||
|
};
|
||||||
|
const subscribeCheckoutDiffCalls: Array<{
|
||||||
|
cwd: string;
|
||||||
|
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||||
|
subscriptionId: string;
|
||||||
|
}> = [];
|
||||||
|
const unsubscribeCheckoutDiffCalls: string[] = [];
|
||||||
|
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
||||||
|
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
||||||
|
|
||||||
|
function on<K extends RouterMessageType>(
|
||||||
|
type: K,
|
||||||
|
handler: (message: Extract<RouterMessage, { type: K }>) => void,
|
||||||
|
): () => void {
|
||||||
|
const routerHandler: RouterHandler = (message) => {
|
||||||
|
if (message.type === type) {
|
||||||
|
handler(message as Extract<RouterMessage, { type: K }>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handlers[type].push(routerHandler);
|
||||||
|
return () => {
|
||||||
|
handlers[type] = handlers[type].filter((candidate) => candidate !== routerHandler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit<K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>): void {
|
||||||
|
for (const handler of handlers[message.type]) {
|
||||||
|
handler(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
on,
|
||||||
|
async subscribeCheckoutDiff(cwd, compare, requestOptions) {
|
||||||
|
subscribeCheckoutDiffCalls.push({
|
||||||
|
cwd,
|
||||||
|
compare,
|
||||||
|
subscriptionId: requestOptions.subscriptionId,
|
||||||
|
});
|
||||||
|
if (config.rejectCheckoutDiffSubscribe) {
|
||||||
|
throw new Error("subscribe failed");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subscriptionId: requestOptions.subscriptionId,
|
||||||
|
cwd,
|
||||||
|
files: [],
|
||||||
|
error: null,
|
||||||
|
requestId: requestOptions.requestId ?? "subscribe-checkout-diff",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
unsubscribeCheckoutDiff(subscriptionId) {
|
||||||
|
unsubscribeCheckoutDiffCalls.push(subscriptionId);
|
||||||
|
},
|
||||||
|
subscribeTerminals(subscription) {
|
||||||
|
subscribeTerminalCalls.push(subscription);
|
||||||
|
},
|
||||||
|
unsubscribeTerminals(subscription) {
|
||||||
|
unsubscribeTerminalCalls.push(subscription);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emit,
|
||||||
|
subscribeCheckoutDiffCalls,
|
||||||
|
unsubscribeCheckoutDiffCalls,
|
||||||
|
subscribeTerminalCalls,
|
||||||
|
unsubscribeTerminalCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||||
|
return {
|
||||||
|
type: "providers_snapshot_update",
|
||||||
|
payload: {
|
||||||
|
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
|
||||||
|
generatedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("server data push router", () => {
|
||||||
|
it("routes provider snapshot and daemon config payloads until detached", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const fake = createFakeClient();
|
||||||
|
const serverId = "server-1";
|
||||||
|
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||||
|
|
||||||
|
fake.emit(providerUpdate("2026-01-01T00:00:00.000Z"));
|
||||||
|
fake.emit({
|
||||||
|
type: "status",
|
||||||
|
payload: { status: "daemon_config_changed", config: daemonConfig },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
|
||||||
|
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
requestId: "providers_snapshot_update",
|
||||||
|
});
|
||||||
|
expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
fake.emit(providerUpdate("2026-01-01T00:00:01.000Z"));
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
|
||||||
|
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
requestId: "providers_snapshot_update",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes active checkout diff queries and writes matching diff events", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const fake = createFakeClient();
|
||||||
|
const serverId = "server-1";
|
||||||
|
const cwd = "/repo";
|
||||||
|
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
||||||
|
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
|
||||||
|
const observer = new QueryObserver(queryClient, {
|
||||||
|
queryKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
meta: checkoutDiffPushRoute({
|
||||||
|
enabled: true,
|
||||||
|
serverId,
|
||||||
|
subscriptionId,
|
||||||
|
cwd,
|
||||||
|
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const unsubscribeObserver = observer.subscribe(() => undefined);
|
||||||
|
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||||
|
|
||||||
|
expect(fake.subscribeCheckoutDiffCalls).toEqual([
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||||
|
subscriptionId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
fake.emit({
|
||||||
|
type: "subscribe_checkout_diff_response",
|
||||||
|
payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(queryKey)).toEqual({
|
||||||
|
cwd,
|
||||||
|
files: [],
|
||||||
|
error: null,
|
||||||
|
requestId: "diff-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
fake.emit({
|
||||||
|
type: "checkout_diff_update",
|
||||||
|
payload: { subscriptionId, cwd, files: [], error: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(queryKey)).toEqual({
|
||||||
|
cwd,
|
||||||
|
files: [],
|
||||||
|
error: null,
|
||||||
|
requestId: `subscription:${subscriptionId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribeObserver();
|
||||||
|
|
||||||
|
expect(fake.unsubscribeCheckoutDiffCalls).toEqual([subscriptionId]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry failed subscriptions on unrelated cache events", async () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true });
|
||||||
|
const serverId = "server-1";
|
||||||
|
const cwd = "/repo";
|
||||||
|
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
||||||
|
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
|
||||||
|
const observer = new QueryObserver(queryClient, {
|
||||||
|
queryKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
meta: checkoutDiffPushRoute({
|
||||||
|
enabled: true,
|
||||||
|
serverId,
|
||||||
|
subscriptionId,
|
||||||
|
cwd,
|
||||||
|
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const unsubscribeObserver = observer.subscribe(() => undefined);
|
||||||
|
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||||
|
|
||||||
|
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
queryClient.setQueryData(["unrelated"], "value");
|
||||||
|
|
||||||
|
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
unsubscribeObserver();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes active terminal queries and filters terminal pushes by workspace", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const fake = createFakeClient();
|
||||||
|
const serverId = "server-1";
|
||||||
|
const cwd = "/repo";
|
||||||
|
const workspaceId = "workspace-a";
|
||||||
|
const queryKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
||||||
|
const observer = new QueryObserver(queryClient, {
|
||||||
|
queryKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
meta: workspaceTerminalsPushRoute({
|
||||||
|
enabled: true,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const unsubscribeObserver = observer.subscribe(() => undefined);
|
||||||
|
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||||
|
|
||||||
|
expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
|
||||||
|
|
||||||
|
fake.emit({
|
||||||
|
type: "terminals_changed",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
terminals: [
|
||||||
|
{ id: "terminal-a", name: "Main", workspaceId },
|
||||||
|
{ id: "terminal-b", name: "Sibling", workspaceId: "workspace-b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(queryKey)).toEqual({
|
||||||
|
cwd,
|
||||||
|
terminals: [{ id: "terminal-a", name: "Main", workspaceId }],
|
||||||
|
requestId: expect.stringMatching(/^terminals-changed-/),
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribeObserver();
|
||||||
|
|
||||||
|
expect(fake.unsubscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-sends active push subscriptions after reconnect", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const fake = createFakeClient();
|
||||||
|
const serverId = "server-1";
|
||||||
|
const cwd = "/repo";
|
||||||
|
const workspaceId = "workspace-a";
|
||||||
|
const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
||||||
|
const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`;
|
||||||
|
const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
||||||
|
const checkoutDiffObserver = new QueryObserver(queryClient, {
|
||||||
|
queryKey: checkoutDiffKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
meta: checkoutDiffPushRoute({
|
||||||
|
enabled: true,
|
||||||
|
serverId,
|
||||||
|
subscriptionId: checkoutDiffSubscriptionId,
|
||||||
|
cwd,
|
||||||
|
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const terminalObserver = new QueryObserver(queryClient, {
|
||||||
|
queryKey: terminalKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
meta: workspaceTerminalsPushRoute({
|
||||||
|
enabled: true,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const unsubscribeCheckoutDiffObserver = checkoutDiffObserver.subscribe(() => undefined);
|
||||||
|
const unsubscribeTerminalObserver = terminalObserver.subscribe(() => undefined);
|
||||||
|
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||||
|
const plainCheckoutDiffObserver = new QueryObserver(queryClient, {
|
||||||
|
queryKey: checkoutDiffKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
const plainTerminalObserver = new QueryObserver(queryClient, {
|
||||||
|
queryKey: terminalKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
const unsubscribePlainCheckoutDiffObserver = plainCheckoutDiffObserver.subscribe(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
const unsubscribePlainTerminalObserver = plainTerminalObserver.subscribe(() => undefined);
|
||||||
|
|
||||||
|
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
||||||
|
|
||||||
|
expect(fake.subscribeCheckoutDiffCalls).toEqual([
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||||
|
subscriptionId: checkoutDiffSubscriptionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||||
|
subscriptionId: checkoutDiffSubscriptionId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(fake.subscribeTerminalCalls).toEqual([
|
||||||
|
{ cwd, workspaceId },
|
||||||
|
{ cwd, workspaceId },
|
||||||
|
]);
|
||||||
|
|
||||||
|
fake.emit({
|
||||||
|
type: "terminals_changed",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
terminals: [
|
||||||
|
{ id: "terminal-a", name: "Main", workspaceId },
|
||||||
|
{ id: "terminal-b", name: "Sibling", workspaceId: "workspace-b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(terminalKey)).toEqual({
|
||||||
|
cwd,
|
||||||
|
terminals: [{ id: "terminal-a", name: "Main", workspaceId }],
|
||||||
|
requestId: expect.stringMatching(/^terminals-changed-/),
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribePlainCheckoutDiffObserver();
|
||||||
|
unsubscribePlainTerminalObserver();
|
||||||
|
unsubscribeCheckoutDiffObserver();
|
||||||
|
unsubscribeTerminalObserver();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes terminal pushes after another observer attaches without push metadata", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const fake = createFakeClient();
|
||||||
|
const serverId = "server-1";
|
||||||
|
const cwd = "/repo";
|
||||||
|
const workspaceId = "workspace-a";
|
||||||
|
const queryKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
||||||
|
const pushObserver = new QueryObserver(queryClient, {
|
||||||
|
queryKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
meta: workspaceTerminalsPushRoute({
|
||||||
|
enabled: true,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const unsubscribePushObserver = pushObserver.subscribe(() => undefined);
|
||||||
|
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||||
|
expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
|
||||||
|
|
||||||
|
const plainObserver = new QueryObserver(queryClient, {
|
||||||
|
queryKey,
|
||||||
|
queryFn: skipToken,
|
||||||
|
enabled: true,
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
const unsubscribePlainObserver = plainObserver.subscribe(() => undefined);
|
||||||
|
|
||||||
|
fake.emit({
|
||||||
|
type: "terminals_changed",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
terminals: [
|
||||||
|
{
|
||||||
|
id: "terminal-a",
|
||||||
|
name: "Main",
|
||||||
|
workspaceId,
|
||||||
|
activity: { state: "idle", attentionReason: "needs_input", changedAt: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(queryKey)).toEqual({
|
||||||
|
cwd,
|
||||||
|
terminals: [
|
||||||
|
{
|
||||||
|
id: "terminal-a",
|
||||||
|
name: "Main",
|
||||||
|
workspaceId,
|
||||||
|
activity: { state: "idle", attentionReason: "needs_input", changedAt: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestId: expect.stringMatching(/^terminals-changed-/),
|
||||||
|
});
|
||||||
|
expect(fake.unsubscribeTerminalCalls).toEqual([]);
|
||||||
|
|
||||||
|
unsubscribePlainObserver();
|
||||||
|
unsubscribePushObserver();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates only the reconnect-repair scopes for one server", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const serverId = "server-1";
|
||||||
|
const otherServerId = "server-2";
|
||||||
|
const providerKey = providersSnapshotQueryKey(serverId);
|
||||||
|
const daemonConfigKey = daemonConfigQueryKey(serverId);
|
||||||
|
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
|
||||||
|
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
|
||||||
|
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
|
||||||
|
|
||||||
|
queryClient.setQueryData(providerKey, { entries: [], generatedAt: "now", requestId: "p" });
|
||||||
|
queryClient.setQueryData(daemonConfigKey, daemonConfig);
|
||||||
|
queryClient.setQueryData(diffKey, { cwd: "/repo", files: [], error: null, requestId: "d" });
|
||||||
|
queryClient.setQueryData(terminalKey, { cwd: "/repo", terminals: [], requestId: "t" });
|
||||||
|
queryClient.setQueryData(otherProviderKey, {
|
||||||
|
entries: [],
|
||||||
|
generatedAt: "now",
|
||||||
|
requestId: "other",
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
||||||
|
|
||||||
|
expect(queryClient.getQueryState(providerKey)?.isInvalidated).toBe(true);
|
||||||
|
expect(queryClient.getQueryState(daemonConfigKey)?.isInvalidated).toBe(true);
|
||||||
|
expect(queryClient.getQueryState(diffKey)?.isInvalidated).toBe(true);
|
||||||
|
expect(queryClient.getQueryState(terminalKey)?.isInvalidated).toBe(true);
|
||||||
|
expect(queryClient.getQueryState(otherProviderKey)?.isInvalidated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
ListTerminalsResponse,
|
||||||
|
MutableDaemonConfig,
|
||||||
|
SessionOutboundMessage,
|
||||||
|
} from "@getpaseo/protocol/messages";
|
||||||
|
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||||
|
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||||
|
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||||
|
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
|
||||||
|
|
||||||
|
type ProvidersSnapshotUpdateMessage = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "providers_snapshot_update" }
|
||||||
|
>;
|
||||||
|
type CheckoutDiffUpdateMessage = Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>;
|
||||||
|
type SubscribeCheckoutDiffResponseMessage = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "subscribe_checkout_diff_response" }
|
||||||
|
>;
|
||||||
|
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||||
|
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||||
|
type ServerDataEventType =
|
||||||
|
| "providers_snapshot_update"
|
||||||
|
| "checkout_diff_update"
|
||||||
|
| "subscribe_checkout_diff_response"
|
||||||
|
| "status"
|
||||||
|
| "terminals_changed";
|
||||||
|
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||||
|
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||||
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
|
|
||||||
|
interface CheckoutDiffCompare {
|
||||||
|
mode: "uncommitted" | "base";
|
||||||
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckoutDiffRoute {
|
||||||
|
domain: "checkoutDiff";
|
||||||
|
enabled: boolean;
|
||||||
|
serverId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
cwd: string;
|
||||||
|
compare: CheckoutDiffCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTerminalsRoute {
|
||||||
|
domain: "workspaceTerminals";
|
||||||
|
enabled: boolean;
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute;
|
||||||
|
|
||||||
|
export interface ServerDataQueryMeta extends Record<string, unknown> {
|
||||||
|
serverData: ServerDataRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
||||||
|
|
||||||
|
interface ServerDataPushClient {
|
||||||
|
on<TType extends ServerDataEventType>(
|
||||||
|
type: TType,
|
||||||
|
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
|
||||||
|
): () => void;
|
||||||
|
subscribeCheckoutDiff(
|
||||||
|
cwd: string,
|
||||||
|
compare: CheckoutDiffCompare,
|
||||||
|
options: { subscriptionId: string; requestId?: string },
|
||||||
|
): Promise<CheckoutDiffResponsePayload>;
|
||||||
|
unsubscribeCheckoutDiff(subscriptionId: string): void;
|
||||||
|
subscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
|
||||||
|
unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushRouterInput {
|
||||||
|
client: ServerDataPushClient;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveServerDataSubscriptions {
|
||||||
|
checkoutDiff: Map<string, CheckoutDiffRoute>;
|
||||||
|
workspaceTerminals: Map<string, WorkspaceTerminalsRoute>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReconnectRepairPolicy {
|
||||||
|
domain: string;
|
||||||
|
invalidate(input: { queryClient: QueryClient; serverId: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [
|
||||||
|
{
|
||||||
|
domain: "providersSnapshot",
|
||||||
|
invalidate: ({ queryClient, serverId }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "daemonConfig",
|
||||||
|
invalidate: ({ queryClient, serverId }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: daemonConfigQueryKey(serverId) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "checkoutDiff",
|
||||||
|
invalidate: ({ queryClient, serverId }) => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => isQueryForServer(query.queryKey, "checkoutDiff", serverId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "workspaceTerminals",
|
||||||
|
invalidate: ({ queryClient, serverId }) => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => isQueryForServer(query.queryKey, "terminals", serverId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const reconnectSubscriptionRepairsByServerId = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
|
export function checkoutDiffPushRoute(input: {
|
||||||
|
enabled: boolean;
|
||||||
|
serverId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
cwd: string;
|
||||||
|
compare: CheckoutDiffCompare;
|
||||||
|
}): ServerDataQueryMeta {
|
||||||
|
return {
|
||||||
|
serverData: {
|
||||||
|
domain: "checkoutDiff",
|
||||||
|
enabled: input.enabled,
|
||||||
|
serverId: input.serverId,
|
||||||
|
subscriptionId: input.subscriptionId,
|
||||||
|
cwd: input.cwd,
|
||||||
|
compare: input.compare,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceTerminalsPushRoute(input: {
|
||||||
|
enabled: boolean;
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
}): ServerDataQueryMeta {
|
||||||
|
return {
|
||||||
|
serverData: {
|
||||||
|
domain: "workspaceTerminals",
|
||||||
|
enabled: input.enabled,
|
||||||
|
serverId: input.serverId,
|
||||||
|
cwd: input.cwd,
|
||||||
|
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateServerDataQueriesAfterReconnect(input: {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
}): void {
|
||||||
|
for (const policy of RECONNECT_REPAIR_POLICIES) {
|
||||||
|
policy.invalidate(input);
|
||||||
|
}
|
||||||
|
for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ??
|
||||||
|
[]) {
|
||||||
|
repairSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProvidersSnapshotUpdate(input: {
|
||||||
|
serverId: string;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
message: ProvidersSnapshotUpdate;
|
||||||
|
}): void {
|
||||||
|
if (input.message.type !== "providers_snapshot_update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
||||||
|
input.queryClient.setQueryData(queryKey, {
|
||||||
|
entries: input.message.payload.entries,
|
||||||
|
generatedAt: input.message.payload.generatedAt,
|
||||||
|
requestId: "providers_snapshot_update",
|
||||||
|
});
|
||||||
|
void input.queryClient.invalidateQueries({
|
||||||
|
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
||||||
|
const activeCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRoute>();
|
||||||
|
const activeTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function reconcileSubscriptions(
|
||||||
|
fallbackActive: ActiveServerDataSubscriptions = {
|
||||||
|
checkoutDiff: activeCheckoutDiffSubscriptions,
|
||||||
|
workspaceTerminals: activeTerminalSubscriptions,
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRoute>();
|
||||||
|
const desiredTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
|
||||||
|
for (const query of input.queryClient.getQueryCache().getAll()) {
|
||||||
|
const route = getActiveServerDataRoute(query, input.serverId, {
|
||||||
|
checkoutDiff: fallbackActive.checkoutDiff,
|
||||||
|
workspaceTerminals: fallbackActive.workspaceTerminals,
|
||||||
|
});
|
||||||
|
if (!route) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (route.domain === "checkoutDiff") {
|
||||||
|
desiredCheckoutDiffSubscriptions.set(route.subscriptionId, route);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
desiredTerminalSubscriptions.set(workspaceTerminalSubscriptionKey(route), route);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileCheckoutDiffSubscriptions({
|
||||||
|
active: activeCheckoutDiffSubscriptions,
|
||||||
|
client: input.client,
|
||||||
|
desired: desiredCheckoutDiffSubscriptions,
|
||||||
|
serverId: input.serverId,
|
||||||
|
});
|
||||||
|
reconcileTerminalSubscriptions({
|
||||||
|
active: activeTerminalSubscriptions,
|
||||||
|
client: input.client,
|
||||||
|
desired: desiredTerminalSubscriptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSubscriptionsAfterReconnect(): void {
|
||||||
|
const fallbackActive = {
|
||||||
|
checkoutDiff: new Map(activeCheckoutDiffSubscriptions),
|
||||||
|
workspaceTerminals: new Map(activeTerminalSubscriptions),
|
||||||
|
};
|
||||||
|
activeCheckoutDiffSubscriptions.clear();
|
||||||
|
activeTerminalSubscriptions.clear();
|
||||||
|
reconcileSubscriptions(fallbackActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeQueryCache = input.queryClient.getQueryCache().subscribe((event) => {
|
||||||
|
if (
|
||||||
|
!shouldReconcileSubscriptionsForCacheEvent(event, input.serverId, {
|
||||||
|
checkoutDiff: activeCheckoutDiffSubscriptions,
|
||||||
|
workspaceTerminals: activeTerminalSubscriptions,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconcileSubscriptions();
|
||||||
|
});
|
||||||
|
const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => {
|
||||||
|
applyProvidersSnapshotUpdate({
|
||||||
|
queryClient: input.queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const unsubscribeDaemonConfig = input.client.on("status", (message) => {
|
||||||
|
applyDaemonConfigStatus({ queryClient: input.queryClient, serverId: input.serverId, message });
|
||||||
|
});
|
||||||
|
const unsubscribeCheckoutDiffUpdate = input.client.on("checkout_diff_update", (message) => {
|
||||||
|
applyCheckoutDiffUpdate({
|
||||||
|
activeCheckoutDiffSubscriptions,
|
||||||
|
queryClient: input.queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const unsubscribeCheckoutDiffResponse = input.client.on(
|
||||||
|
"subscribe_checkout_diff_response",
|
||||||
|
(message) => {
|
||||||
|
applyCheckoutDiffSubscribeResponse({
|
||||||
|
activeCheckoutDiffSubscriptions,
|
||||||
|
queryClient: input.queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const unsubscribeTerminalsChanged = input.client.on("terminals_changed", (message) => {
|
||||||
|
applyTerminalsChanged({
|
||||||
|
activeCheckoutDiffSubscriptions,
|
||||||
|
activeTerminalSubscriptions,
|
||||||
|
queryClient: input.queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let reconnectSubscriptionRepairs = reconnectSubscriptionRepairsByServerId.get(input.serverId);
|
||||||
|
if (!reconnectSubscriptionRepairs) {
|
||||||
|
reconnectSubscriptionRepairs = new Set();
|
||||||
|
reconnectSubscriptionRepairsByServerId.set(input.serverId, reconnectSubscriptionRepairs);
|
||||||
|
}
|
||||||
|
reconnectSubscriptionRepairs.add(resetSubscriptionsAfterReconnect);
|
||||||
|
|
||||||
|
reconcileSubscriptions();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
reconnectSubscriptionRepairs.delete(resetSubscriptionsAfterReconnect);
|
||||||
|
if (reconnectSubscriptionRepairs.size === 0) {
|
||||||
|
reconnectSubscriptionRepairsByServerId.delete(input.serverId);
|
||||||
|
}
|
||||||
|
unsubscribeQueryCache();
|
||||||
|
unsubscribeProviders();
|
||||||
|
unsubscribeDaemonConfig();
|
||||||
|
unsubscribeCheckoutDiffUpdate();
|
||||||
|
unsubscribeCheckoutDiffResponse();
|
||||||
|
unsubscribeTerminalsChanged();
|
||||||
|
for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) {
|
||||||
|
unsubscribeCheckoutDiff(input.client, subscriptionId);
|
||||||
|
}
|
||||||
|
activeCheckoutDiffSubscriptions.clear();
|
||||||
|
for (const route of activeTerminalSubscriptions.values()) {
|
||||||
|
input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(route));
|
||||||
|
}
|
||||||
|
activeTerminalSubscriptions.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileCheckoutDiffSubscriptions(input: {
|
||||||
|
active: Map<string, CheckoutDiffRoute>;
|
||||||
|
client: ServerDataPushClient;
|
||||||
|
desired: Map<string, CheckoutDiffRoute>;
|
||||||
|
serverId: string;
|
||||||
|
}): void {
|
||||||
|
for (const [subscriptionId, current] of input.active) {
|
||||||
|
const desired = input.desired.get(subscriptionId);
|
||||||
|
if (desired && areCheckoutDiffRoutesEqual(current, desired)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unsubscribeCheckoutDiff(input.client, subscriptionId);
|
||||||
|
input.active.delete(subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [subscriptionId, desired] of input.desired) {
|
||||||
|
if (input.active.has(subscriptionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
input.active.set(subscriptionId, desired);
|
||||||
|
void input.client
|
||||||
|
.subscribeCheckoutDiff(desired.cwd, desired.compare, {
|
||||||
|
subscriptionId,
|
||||||
|
requestId: `push-router:${input.serverId}:${subscriptionId}`,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (areCheckoutDiffRoutesEqual(input.active.get(subscriptionId), desired)) {
|
||||||
|
input.active.delete(subscriptionId);
|
||||||
|
}
|
||||||
|
console.error("[server-data] subscribeCheckoutDiff failed", {
|
||||||
|
serverId: input.serverId,
|
||||||
|
cwd: desired.cwd,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileTerminalSubscriptions(input: {
|
||||||
|
active: Map<string, WorkspaceTerminalsRoute>;
|
||||||
|
client: ServerDataPushClient;
|
||||||
|
desired: Map<string, WorkspaceTerminalsRoute>;
|
||||||
|
}): void {
|
||||||
|
for (const [key, current] of input.active) {
|
||||||
|
const desired = input.desired.get(key);
|
||||||
|
if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(current));
|
||||||
|
input.active.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, desired] of input.desired) {
|
||||||
|
if (input.active.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
input.active.set(key, desired);
|
||||||
|
input.client.subscribeTerminals(workspaceTerminalSubscriptionInput(desired));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDaemonConfigStatus(input: {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
message: StatusMessage;
|
||||||
|
}): void {
|
||||||
|
const payload = input.message.payload;
|
||||||
|
if (!isDaemonConfigChangedPayload(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.queryClient.setQueryData<MutableDaemonConfig>(
|
||||||
|
daemonConfigQueryKey(input.serverId),
|
||||||
|
payload.config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCheckoutDiffUpdate(input: {
|
||||||
|
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
message: CheckoutDiffUpdateMessage;
|
||||||
|
}): void {
|
||||||
|
setCheckoutDiffPayload({
|
||||||
|
activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions,
|
||||||
|
queryClient: input.queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
subscriptionId: input.message.payload.subscriptionId,
|
||||||
|
payload: {
|
||||||
|
cwd: input.message.payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(input.message.payload.files),
|
||||||
|
error: input.message.payload.error,
|
||||||
|
requestId: `subscription:${input.message.payload.subscriptionId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCheckoutDiffSubscribeResponse(input: {
|
||||||
|
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
message: SubscribeCheckoutDiffResponseMessage;
|
||||||
|
}): void {
|
||||||
|
setCheckoutDiffPayload({
|
||||||
|
activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions,
|
||||||
|
queryClient: input.queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
subscriptionId: input.message.payload.subscriptionId,
|
||||||
|
payload: {
|
||||||
|
cwd: input.message.payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(input.message.payload.files),
|
||||||
|
error: input.message.payload.error,
|
||||||
|
requestId: input.message.payload.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCheckoutDiffPayload(input: {
|
||||||
|
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
payload: CheckoutDiffCachePayload;
|
||||||
|
}): void {
|
||||||
|
for (const query of input.queryClient.getQueryCache().getAll()) {
|
||||||
|
const route =
|
||||||
|
getServerDataRoute(query) ??
|
||||||
|
getActiveCheckoutDiffRouteForQueryKey({
|
||||||
|
active: input.activeCheckoutDiffSubscriptions,
|
||||||
|
queryKey: query.queryKey,
|
||||||
|
serverId: input.serverId,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!route ||
|
||||||
|
route.domain !== "checkoutDiff" ||
|
||||||
|
route.serverId !== input.serverId ||
|
||||||
|
route.subscriptionId !== input.subscriptionId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
input.queryClient.setQueryData<CheckoutDiffCachePayload>(query.queryKey, input.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTerminalsChanged(input: {
|
||||||
|
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
||||||
|
activeTerminalSubscriptions: Map<string, WorkspaceTerminalsRoute>;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
message: TerminalsChangedMessage;
|
||||||
|
}): void {
|
||||||
|
for (const query of input.queryClient.getQueryCache().getAll()) {
|
||||||
|
const route = getActiveServerDataRoute(query, input.serverId, {
|
||||||
|
checkoutDiff: input.activeCheckoutDiffSubscriptions,
|
||||||
|
workspaceTerminals: input.activeTerminalSubscriptions,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!route ||
|
||||||
|
route.domain !== "workspaceTerminals" ||
|
||||||
|
route.cwd !== input.message.payload.cwd
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingTerminals = input.message.payload.terminals.filter(
|
||||||
|
(terminal) => terminal.workspaceId === route.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
input.queryClient.setQueryData<ListTerminalsPayload>(query.queryKey, (current) => ({
|
||||||
|
cwd: input.message.payload.cwd,
|
||||||
|
terminals: matchingTerminals,
|
||||||
|
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveServerDataRoute(
|
||||||
|
query: Query,
|
||||||
|
serverId: string,
|
||||||
|
active: ActiveServerDataSubscriptions,
|
||||||
|
): ServerDataRoute | null {
|
||||||
|
if (query.getObserversCount() === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const route = getServerDataRoute(query);
|
||||||
|
if (route) {
|
||||||
|
return route.enabled && route.serverId === serverId ? route : null;
|
||||||
|
}
|
||||||
|
return getActiveRouteForQueryKey({
|
||||||
|
active,
|
||||||
|
queryKey: query.queryKey,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRouteForQueryKey(input: {
|
||||||
|
active: ActiveServerDataSubscriptions;
|
||||||
|
queryKey: QueryKey;
|
||||||
|
serverId: string;
|
||||||
|
}): ServerDataRoute | null {
|
||||||
|
return (
|
||||||
|
getActiveTerminalRouteForQueryKey({
|
||||||
|
active: input.active.workspaceTerminals,
|
||||||
|
queryKey: input.queryKey,
|
||||||
|
serverId: input.serverId,
|
||||||
|
}) ??
|
||||||
|
getActiveCheckoutDiffRouteForQueryKey({
|
||||||
|
active: input.active.checkoutDiff,
|
||||||
|
queryKey: input.queryKey,
|
||||||
|
serverId: input.serverId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveTerminalRouteForQueryKey(input: {
|
||||||
|
active: Map<string, WorkspaceTerminalsRoute>;
|
||||||
|
queryKey: QueryKey;
|
||||||
|
serverId: string;
|
||||||
|
}): WorkspaceTerminalsRoute | null {
|
||||||
|
if (!isQueryForServer(input.queryKey, "terminals", input.serverId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cwd = input.queryKey[2];
|
||||||
|
const workspaceId = input.queryKey[3];
|
||||||
|
if (
|
||||||
|
typeof cwd !== "string" ||
|
||||||
|
(workspaceId !== undefined && workspaceId !== null && typeof workspaceId !== "string")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return input.active.get(`${cwd}\u0000${workspaceId ?? ""}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveCheckoutDiffRouteForQueryKey(input: {
|
||||||
|
active: Map<string, CheckoutDiffRoute>;
|
||||||
|
queryKey: QueryKey;
|
||||||
|
serverId: string;
|
||||||
|
}): CheckoutDiffRoute | null {
|
||||||
|
if (!isQueryForServer(input.queryKey, "checkoutDiff", input.serverId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const route of input.active.values()) {
|
||||||
|
if (isCheckoutDiffQueryKeyForRoute(input.queryKey, route)) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReconcileSubscriptionsForCacheEvent(
|
||||||
|
event: QueryCacheNotifyEvent,
|
||||||
|
serverId: string,
|
||||||
|
active: ActiveServerDataSubscriptions,
|
||||||
|
): boolean {
|
||||||
|
if (!canEventChangeDesiredSubscriptions(event.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const route = getServerDataRoute(event.query);
|
||||||
|
if (route?.serverId === serverId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
getActiveRouteForQueryKey({
|
||||||
|
active,
|
||||||
|
queryKey: event.query.queryKey,
|
||||||
|
serverId,
|
||||||
|
}) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]): boolean {
|
||||||
|
return (
|
||||||
|
type === "added" ||
|
||||||
|
type === "removed" ||
|
||||||
|
type === "observerAdded" ||
|
||||||
|
type === "observerRemoved" ||
|
||||||
|
type === "observerOptionsUpdated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerDataRoute(query: Query): ServerDataRoute | null {
|
||||||
|
const meta = query.meta;
|
||||||
|
if (!isRecord(meta) || !isRecord(meta.serverData)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readServerDataRoute(meta.serverData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute | null {
|
||||||
|
const domain = value.domain;
|
||||||
|
const enabled = value.enabled;
|
||||||
|
const serverId = value.serverId;
|
||||||
|
const cwd = value.cwd;
|
||||||
|
if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain === "checkoutDiff") {
|
||||||
|
const subscriptionId = value.subscriptionId;
|
||||||
|
const compare = readCheckoutDiffCompare(value.compare);
|
||||||
|
if (typeof subscriptionId !== "string" || !compare) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
enabled,
|
||||||
|
serverId,
|
||||||
|
subscriptionId,
|
||||||
|
cwd,
|
||||||
|
compare,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain === "workspaceTerminals") {
|
||||||
|
const workspaceId = value.workspaceId;
|
||||||
|
if (workspaceId !== undefined && typeof workspaceId !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
enabled,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = value.mode;
|
||||||
|
const baseRef = value.baseRef;
|
||||||
|
const ignoreWhitespace = value.ignoreWhitespace;
|
||||||
|
if (mode !== "uncommitted" && mode !== "base") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (baseRef !== undefined && typeof baseRef !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ignoreWhitespace !== undefined && typeof ignoreWhitespace !== "boolean") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
...(baseRef ? { baseRef } : {}),
|
||||||
|
...(ignoreWhitespace !== undefined ? { ignoreWhitespace } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function areCheckoutDiffRoutesEqual(
|
||||||
|
left: CheckoutDiffRoute | undefined,
|
||||||
|
right: CheckoutDiffRoute,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left?.serverId === right.serverId &&
|
||||||
|
left.subscriptionId === right.subscriptionId &&
|
||||||
|
left.cwd === right.cwd &&
|
||||||
|
left.compare.mode === right.compare.mode &&
|
||||||
|
left.compare.baseRef === right.compare.baseRef &&
|
||||||
|
left.compare.ignoreWhitespace === right.compare.ignoreWhitespace
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffRoute): boolean {
|
||||||
|
return (
|
||||||
|
queryKey[0] === "checkoutDiff" &&
|
||||||
|
queryKey[1] === route.serverId &&
|
||||||
|
queryKey[2] === route.cwd &&
|
||||||
|
queryKey[3] === route.compare.mode &&
|
||||||
|
queryKey[4] === (route.compare.baseRef ?? "") &&
|
||||||
|
queryKey[5] === (route.compare.ignoreWhitespace === true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function areWorkspaceTerminalsRoutesEqual(
|
||||||
|
left: WorkspaceTerminalsRoute,
|
||||||
|
right: WorkspaceTerminalsRoute,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.serverId === right.serverId &&
|
||||||
|
left.cwd === right.cwd &&
|
||||||
|
left.workspaceId === right.workspaceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceTerminalSubscriptionKey(route: WorkspaceTerminalsRoute): string {
|
||||||
|
return `${route.cwd}\u0000${route.workspaceId ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): {
|
||||||
|
cwd: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
cwd: route.cwd,
|
||||||
|
...(route.workspaceId ? { workspaceId: route.workspaceId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void {
|
||||||
|
try {
|
||||||
|
client.unsubscribeCheckoutDiff(subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// Disconnect cleanup can race with explicit subscription teardown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean {
|
||||||
|
return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDaemonConfigChangedPayload(
|
||||||
|
payload: StatusMessage["payload"],
|
||||||
|
): payload is { status: "daemon_config_changed"; config: MutableDaemonConfig } {
|
||||||
|
return payload.status === "daemon_config_changed" && isRecord(payload.config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
skipToken,
|
||||||
|
useQuery,
|
||||||
|
type QueryKey,
|
||||||
|
type UseQueryOptions,
|
||||||
|
type UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type QueryFnOption<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = NonNullable<
|
||||||
|
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>["queryFn"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
||||||
|
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||||
|
| "gcTime"
|
||||||
|
| "initialData"
|
||||||
|
| "refetchOnMount"
|
||||||
|
| "refetchOnReconnect"
|
||||||
|
| "refetchOnWindowFocus"
|
||||||
|
| "staleTime"
|
||||||
|
> & {
|
||||||
|
pushEvent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
||||||
|
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||||
|
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
|
||||||
|
> & {
|
||||||
|
dataShape: "list" | "value";
|
||||||
|
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||||
|
staleTimeMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useReplicaQuery<
|
||||||
|
TQueryFnData,
|
||||||
|
TError = Error,
|
||||||
|
TData = TQueryFnData,
|
||||||
|
TQueryKey extends QueryKey = QueryKey,
|
||||||
|
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||||
|
return useQuery(replicaQueryOptions(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetchQuery<
|
||||||
|
TQueryFnData,
|
||||||
|
TError = Error,
|
||||||
|
TData = TQueryFnData,
|
||||||
|
TQueryKey extends QueryKey = QueryKey,
|
||||||
|
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||||
|
return useQuery(fetchQueryOptions(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replicaQueryOptions<
|
||||||
|
TQueryFnData,
|
||||||
|
TError = Error,
|
||||||
|
TData = TQueryFnData,
|
||||||
|
TQueryKey extends QueryKey = QueryKey,
|
||||||
|
>(
|
||||||
|
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||||
|
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
||||||
|
const { pushEvent, meta, ...options } = input;
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
gcTime: Infinity,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
serverDataPolicy: {
|
||||||
|
class: "replica",
|
||||||
|
pushEvent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryFn: options.queryFn ?? skipToken,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchQueryOptions<
|
||||||
|
TQueryFnData,
|
||||||
|
TError = Error,
|
||||||
|
TData = TQueryFnData,
|
||||||
|
TQueryKey extends QueryKey = QueryKey,
|
||||||
|
>(
|
||||||
|
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||||
|
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
||||||
|
if (!Number.isFinite(input.staleTimeMs)) {
|
||||||
|
throw new Error("Fetch queries must declare a finite staleTimeMs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataShape, meta, staleTimeMs, ...options } = input;
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
serverDataPolicy: {
|
||||||
|
class: "fetch",
|
||||||
|
dataShape,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
refetchOnMount: "always",
|
||||||
|
staleTime: staleTimeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ export interface DesktopDaemonLogs {
|
|||||||
contents: string;
|
contents: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopAppLogs {
|
||||||
|
logPath: string;
|
||||||
|
contents: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopPairingOffer {
|
export interface DesktopPairingOffer {
|
||||||
relayEnabled: boolean;
|
relayEnabled: boolean;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
@@ -144,6 +149,17 @@ export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
|
|||||||
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
|
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
|
||||||
|
const raw = await invokeDesktopCommand("desktop_app_logs");
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("Unexpected desktop app logs response.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||||
|
contents: typeof raw.contents === "string" ? raw.contents : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||||
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface UseBuiltInDaemonManagementInput {
|
|||||||
interface UseBuiltInDaemonManagementResult {
|
interface UseBuiltInDaemonManagementResult {
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
enable: () => Promise<DaemonManagementToggleResult | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBuiltInDaemonManagement(
|
export function useBuiltInDaemonManagement(
|
||||||
@@ -42,12 +43,16 @@ export function useBuiltInDaemonManagement(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
||||||
const reportError = useDesktopIpcErrorReporter();
|
const reportError = useDesktopIpcErrorReporter();
|
||||||
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
|
const {
|
||||||
DaemonManagementToggleResult,
|
mutate: toggleDaemonManagement,
|
||||||
Error
|
mutateAsync: toggleDaemonManagementAsync,
|
||||||
>({
|
isPending: isUpdating,
|
||||||
mutationFn: async () => {
|
} = useMutation<DaemonManagementToggleResult, Error, { forceEnable: boolean }>({
|
||||||
const wasManagingDaemon = settings.manageBuiltInDaemon;
|
mutationFn: async ({ forceEnable }) => {
|
||||||
|
// forceEnable takes the enable branch regardless of the persisted
|
||||||
|
// setting — the recovery affordance must never reach the stop/confirm
|
||||||
|
// path even if manageBuiltInDaemon was left true.
|
||||||
|
const wasManagingDaemon = forceEnable ? false : settings.manageBuiltInDaemon;
|
||||||
try {
|
try {
|
||||||
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
||||||
confirm: () =>
|
confirm: () =>
|
||||||
@@ -109,8 +114,21 @@ export function useBuiltInDaemonManagement(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleDaemonManagement();
|
toggleDaemonManagement({ forceEnable: false });
|
||||||
}, [isUpdating, toggleDaemonManagement]);
|
}, [isUpdating, toggleDaemonManagement]);
|
||||||
|
|
||||||
return { isUpdating, toggle };
|
const enable = useCallback(async () => {
|
||||||
|
if (isUpdating) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await toggleDaemonManagementAsync({ forceEnable: true });
|
||||||
|
} catch {
|
||||||
|
// onError has already surfaced the failure; callers only act on success.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [isUpdating, toggleDaemonManagementAsync]);
|
||||||
|
|
||||||
|
return { isUpdating, toggle, enable };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { getIsElectron } from "@/constants/platform";
|
||||||
|
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
|
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||||
|
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export interface EnableBuiltInDaemonOption {
|
||||||
|
visible: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||||
|
const isElectron = getIsElectron();
|
||||||
|
const localDaemon = useLocalDaemonServerIdState();
|
||||||
|
const hosts = useHosts();
|
||||||
|
const { settings, updateSettings } = useDesktopSettings();
|
||||||
|
const { data, setStatus, refetch } = useDaemonStatus();
|
||||||
|
const { enable } = useBuiltInDaemonManagement({
|
||||||
|
daemonStatus: data?.status ?? null,
|
||||||
|
settings: settings.daemon,
|
||||||
|
updateSettings: (updates) => updateSettings({ daemon: updates }),
|
||||||
|
setStatus,
|
||||||
|
refreshStatus: refetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLocalhostConfigured =
|
||||||
|
localDaemon.status === "resolved" &&
|
||||||
|
localDaemon.serverId !== null &&
|
||||||
|
hosts.some((host) => host.serverId === localDaemon.serverId);
|
||||||
|
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
||||||
|
|
||||||
|
const onPress = useCallback(() => {
|
||||||
|
void (async () => {
|
||||||
|
const result = await enable();
|
||||||
|
if (result?.kind === "enabled") {
|
||||||
|
router.push(buildSettingsHostSectionRoute(result.newStatus.serverId, "host"));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [enable]);
|
||||||
|
|
||||||
|
return { visible, onPress };
|
||||||
|
}
|
||||||
@@ -128,11 +128,11 @@ export interface DesktopBrowserNewTabRequestEvent {
|
|||||||
|
|
||||||
export interface DesktopBrowserBridge {
|
export interface DesktopBrowserBridge {
|
||||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
||||||
|
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
|
||||||
setWorkspaceActiveBrowser?: (input: {
|
setWorkspaceActiveBrowser?: (input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
browserId: string | null;
|
browserId: string | null;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
setAgentActiveBrowser?: (input: { agentId: string; browserId: string | null }) => Promise<void>;
|
|
||||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||||
clearPartition?: (browserId: string) => Promise<void>;
|
clearPartition?: (browserId: string) => Promise<void>;
|
||||||
executeAutomationCommand?: (
|
executeAutomationCommand?: (
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe("app diagnostics report", () => {
|
|||||||
const host = makeHost();
|
const host = makeHost();
|
||||||
const redacted = redactAppDiagnosticReport(
|
const redacted = redactAppDiagnosticReport(
|
||||||
[
|
[
|
||||||
|
"Desktop app log tail",
|
||||||
"secret.example.test:6767",
|
"secret.example.test:6767",
|
||||||
"relay.secret.test:443",
|
"relay.secret.test:443",
|
||||||
"daemon-public-key-secret",
|
"daemon-public-key-secret",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
collectDesktopDiagnosticSections,
|
||||||
|
type DesktopDiagnosticSources,
|
||||||
|
} from "./desktop-diagnostic-report";
|
||||||
|
|
||||||
|
function makeSources(): DesktopDiagnosticSources {
|
||||||
|
return {
|
||||||
|
getStatus: async () => ({
|
||||||
|
serverId: "server-1",
|
||||||
|
status: "running",
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
hostname: "host",
|
||||||
|
pid: 4242,
|
||||||
|
home: "/paseo/home",
|
||||||
|
version: "1.2.3",
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
getDaemonLogs: async () => ({
|
||||||
|
logPath: "/paseo/home/daemon.log",
|
||||||
|
contents: "daemon line one\ndaemon line two",
|
||||||
|
}),
|
||||||
|
getAppLogs: async () => ({
|
||||||
|
logPath: "/logs/Paseo/main.log",
|
||||||
|
contents: "[login-shell-env] start\n[login-shell-env] failed",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("desktop diagnostic report", () => {
|
||||||
|
test("starts desktop diagnostic requests together", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let releaseAppLogs: () => void = () => {};
|
||||||
|
const appLogGate = new Promise<void>((resolve) => {
|
||||||
|
releaseAppLogs = resolve;
|
||||||
|
});
|
||||||
|
const sources: DesktopDiagnosticSources = {
|
||||||
|
...makeSources(),
|
||||||
|
getStatus: async () => {
|
||||||
|
calls.push("status");
|
||||||
|
return makeSources().getStatus();
|
||||||
|
},
|
||||||
|
getDaemonLogs: async () => {
|
||||||
|
calls.push("daemonLogs");
|
||||||
|
return makeSources().getDaemonLogs();
|
||||||
|
},
|
||||||
|
getAppLogs: async () => {
|
||||||
|
calls.push("appLogs");
|
||||||
|
await appLogGate;
|
||||||
|
return makeSources().getAppLogs();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultPromise = collectDesktopDiagnosticSections(sources);
|
||||||
|
|
||||||
|
expect(calls).toEqual(["status", "daemonLogs", "appLogs"]);
|
||||||
|
releaseAppLogs();
|
||||||
|
await expect(resultPromise).resolves.toMatchObject({ status: "done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes the Electron main-process log after the daemon log", async () => {
|
||||||
|
const result = await collectDesktopDiagnosticSections(makeSources());
|
||||||
|
const report = result.sections.join("\n\n");
|
||||||
|
|
||||||
|
expect(result.status).toBe("done");
|
||||||
|
expect(report).toContain(" Log path: /paseo/home/daemon.log");
|
||||||
|
expect(report).toContain(" App log path: /logs/Paseo/main.log");
|
||||||
|
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
|
||||||
|
expect(report).toContain(
|
||||||
|
"Desktop app log tail\n [login-shell-env] start\n [login-shell-env] failed",
|
||||||
|
);
|
||||||
|
expect(report.indexOf("Desktop app log tail")).toBeGreaterThan(
|
||||||
|
report.indexOf("Desktop daemon log tail"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps daemon diagnostics when the Electron app log fails", async () => {
|
||||||
|
const sources = {
|
||||||
|
...makeSources(),
|
||||||
|
getAppLogs: async () => {
|
||||||
|
throw new Error("app log unavailable");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collectDesktopDiagnosticSections(sources);
|
||||||
|
const report = result.sections.join("\n\n");
|
||||||
|
|
||||||
|
expect(result.status).toBe("failed");
|
||||||
|
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
|
||||||
|
expect(report).toContain("Desktop app log tail\n Error: app log unavailable");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
getDesktopAppLogs,
|
||||||
|
getDesktopDaemonLogs,
|
||||||
|
getDesktopDaemonStatus,
|
||||||
|
type DesktopAppLogs,
|
||||||
|
type DesktopDaemonLogs,
|
||||||
|
type DesktopDaemonStatus,
|
||||||
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { formatDiagnosticSection } from "./app-diagnostic-report";
|
||||||
|
|
||||||
|
type DesktopDiagnosticStatus = "done" | "failed";
|
||||||
|
|
||||||
|
export interface DesktopDiagnosticCollectionResult {
|
||||||
|
sections: string[];
|
||||||
|
status: DesktopDiagnosticStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopDiagnosticSources {
|
||||||
|
getStatus: () => Promise<DesktopDaemonStatus>;
|
||||||
|
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
|
||||||
|
getAppLogs: () => Promise<DesktopAppLogs>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = {
|
||||||
|
getStatus: getDesktopDaemonStatus,
|
||||||
|
getDaemonLogs: getDesktopDaemonLogs,
|
||||||
|
getAppLogs: getDesktopAppLogs,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function collectDesktopDiagnosticSections(
|
||||||
|
sources: DesktopDiagnosticSources = DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES,
|
||||||
|
): Promise<DesktopDiagnosticCollectionResult> {
|
||||||
|
const sections: string[] = [];
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
const [daemonResult, appLogsResult] = await Promise.allSettled([
|
||||||
|
Promise.all([sources.getStatus(), sources.getDaemonLogs()]),
|
||||||
|
sources.getAppLogs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (daemonResult.status === "fulfilled") {
|
||||||
|
const [status, daemonLogs] = daemonResult.value;
|
||||||
|
const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null;
|
||||||
|
sections.unshift(...formatDesktopDaemonSections({ status, daemonLogs, appLogs }));
|
||||||
|
} else {
|
||||||
|
failed = true;
|
||||||
|
sections.unshift(
|
||||||
|
formatDiagnosticSection("Desktop", [
|
||||||
|
{ label: "Error", value: toMessage(daemonResult.reason) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appLogsResult.status === "fulfilled") {
|
||||||
|
sections.push(formatLogTailSection("Desktop app log tail", appLogsResult.value.contents));
|
||||||
|
} else {
|
||||||
|
failed = true;
|
||||||
|
sections.push(
|
||||||
|
formatDiagnosticSection("Desktop app log tail", [
|
||||||
|
{ label: "Error", value: toMessage(appLogsResult.reason) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: failed ? "failed" : "done",
|
||||||
|
sections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDesktopDaemonSections(input: {
|
||||||
|
status: DesktopDaemonStatus;
|
||||||
|
daemonLogs: DesktopDaemonLogs;
|
||||||
|
appLogs: DesktopAppLogs | null;
|
||||||
|
}): string[] {
|
||||||
|
const { status, daemonLogs, appLogs } = input;
|
||||||
|
return [
|
||||||
|
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: daemonLogs.logPath || "unknown" },
|
||||||
|
{ label: "App log path", value: appLogs?.logPath || "unavailable" },
|
||||||
|
{ label: "Error", value: status.error ?? "none" },
|
||||||
|
]),
|
||||||
|
formatLogTailSection("Desktop daemon log tail", daemonLogs.contents),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogTailSection(title: string, contents: string): string {
|
||||||
|
return [title, contents ? indentBlock(contents) : " No log lines found"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
|
||||||
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
|
|
||||||
|
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
|
||||||
|
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(items: DiffFlatItem[]): string[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
if (item.type === "folder") {
|
||||||
|
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
|
||||||
|
}
|
||||||
|
const base = item.file.path.split("/").pop();
|
||||||
|
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildDiffFlatItems", () => {
|
||||||
|
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
|
||||||
|
|
||||||
|
it("emits a body and a sticky index for each expanded file", () => {
|
||||||
|
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
||||||
|
files: [createFile("a.ts"), createFile("b.ts")],
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(["a.ts"]),
|
||||||
|
});
|
||||||
|
// Root-level files (no folder): header, body (for expanded a.ts), header
|
||||||
|
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
|
||||||
|
// sticky points at the header (index 0), not the body
|
||||||
|
expect(stickyHeaderIndices).toEqual([0]);
|
||||||
|
expect(items[0].type).toBe("header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the old flat list without folder rows or indentation", () => {
|
||||||
|
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode: "flat",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(["src/app/a.ts"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
|
||||||
|
expect(stickyHeaderIndices).toEqual([0]);
|
||||||
|
expect(items.every((item) => item.type !== "folder")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups files under compressed folder rows, all expanded by default", () => {
|
||||||
|
const { items } = buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(),
|
||||||
|
});
|
||||||
|
// dirs sort before files within a level: [nested] precedes a.ts
|
||||||
|
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapsing a folder hides its descendants but keeps the row", () => {
|
||||||
|
const { items } = buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(["src/app/nested"]),
|
||||||
|
expandedPaths: new Set(),
|
||||||
|
});
|
||||||
|
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapsing an ancestor hides everything below it", () => {
|
||||||
|
const { items } = buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(["src/app"]),
|
||||||
|
expandedPaths: new Set(),
|
||||||
|
});
|
||||||
|
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives sticky indices file-headers-only from the post-collapse list", () => {
|
||||||
|
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
|
||||||
|
// header at index 3, with the body right after.
|
||||||
|
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(["src/app/a.ts"]),
|
||||||
|
});
|
||||||
|
expect(summarize(items)).toEqual([
|
||||||
|
"[src/app]",
|
||||||
|
" [nested]",
|
||||||
|
" b.ts",
|
||||||
|
" a.ts",
|
||||||
|
" body:a.ts",
|
||||||
|
]);
|
||||||
|
expect(stickyHeaderIndices).toEqual([3]);
|
||||||
|
for (const idx of stickyHeaderIndices) {
|
||||||
|
expect(items[idx].type).toBe("header");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps tree file rows back to their original files array index", () => {
|
||||||
|
const { items } = buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(),
|
||||||
|
});
|
||||||
|
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
|
||||||
|
const headers = items.filter((i) => i.type === "header");
|
||||||
|
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sumHeightsBefore", () => {
|
||||||
|
const items = buildDiffFlatItems({
|
||||||
|
files: [createFile("src/a.ts"), createFile("src/b.ts")],
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(),
|
||||||
|
}).items; // [folder, a.ts, b.ts]
|
||||||
|
|
||||||
|
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
|
||||||
|
|
||||||
|
it("sums the heights of items before the index, counting folder rows", () => {
|
||||||
|
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
|
||||||
|
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
|
||||||
|
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an out-of-range index to the list length", () => {
|
||||||
|
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
|
import {
|
||||||
|
buildDiffTree,
|
||||||
|
compressSingleChildChains,
|
||||||
|
flattenDiffTree,
|
||||||
|
type DiffTreeDirNode,
|
||||||
|
} from "@/git/diff-tree";
|
||||||
|
|
||||||
|
// The row model for the Changes FlatList. `fileIndex` is the file's index within
|
||||||
|
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
|
||||||
|
// independent of folder rows and collapse state. `depth` drives indentation.
|
||||||
|
export type DiffFlatItem =
|
||||||
|
| {
|
||||||
|
type: "folder";
|
||||||
|
dirPath: string;
|
||||||
|
displayName: string;
|
||||||
|
depth: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
|
||||||
|
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
|
||||||
|
|
||||||
|
export interface DiffFlatItemsResult {
|
||||||
|
items: DiffFlatItem[];
|
||||||
|
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
|
||||||
|
stickyHeaderIndices: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildDiffFlatItemsInput {
|
||||||
|
files: ParsedDiffFile[];
|
||||||
|
viewMode: "flat" | "tree";
|
||||||
|
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
|
||||||
|
collapsedFolders: ReadonlySet<string>;
|
||||||
|
/** File paths whose diff body is expanded. */
|
||||||
|
expandedPaths: ReadonlySet<string>;
|
||||||
|
/**
|
||||||
|
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
|
||||||
|
* the tree on every collapse toggle; omitted callers (tests) build it internally.
|
||||||
|
*/
|
||||||
|
tree?: DiffTreeDirNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the FlatList item model plus its sticky-header indices: files grouped
|
||||||
|
* under directory rows, single-child chains compressed, descendants of collapsed
|
||||||
|
* folders omitted.
|
||||||
|
*
|
||||||
|
* Sticky indices are derived from the FINAL (post-collapse) item list and only
|
||||||
|
* ever point at expanded FILE headers, so folder rows can't stack or collide in
|
||||||
|
* the sticky header stack.
|
||||||
|
*/
|
||||||
|
export function buildDiffFlatItems({
|
||||||
|
files,
|
||||||
|
viewMode,
|
||||||
|
collapsedFolders,
|
||||||
|
expandedPaths,
|
||||||
|
tree,
|
||||||
|
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
|
||||||
|
const items: DiffFlatItem[] = [];
|
||||||
|
const stickyHeaderIndices: number[] = [];
|
||||||
|
|
||||||
|
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
|
||||||
|
const isExpanded = expandedPaths.has(file.path);
|
||||||
|
items.push({ type: "header", file, fileIndex, isExpanded, depth });
|
||||||
|
if (isExpanded) {
|
||||||
|
stickyHeaderIndices.push(items.length - 1);
|
||||||
|
items.push({ type: "body", file, fileIndex, depth });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (viewMode === "flat") {
|
||||||
|
for (const [fileIndex, file] of files.entries()) {
|
||||||
|
pushFile(file, fileIndex, 0);
|
||||||
|
}
|
||||||
|
return { items, stickyHeaderIndices };
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
|
||||||
|
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
|
||||||
|
const rows = flattenDiffTree(compressedTree, collapsedFolders);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.kind === "folder") {
|
||||||
|
items.push({
|
||||||
|
type: "folder",
|
||||||
|
dirPath: row.dirPath,
|
||||||
|
displayName: row.displayName,
|
||||||
|
depth: row.depth,
|
||||||
|
collapsed: collapsedFolders.has(row.dirPath),
|
||||||
|
additions: row.additions,
|
||||||
|
deletions: row.deletions,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fileIndex = indexByPath.get(row.file.path);
|
||||||
|
if (fileIndex === undefined) {
|
||||||
|
// Should never happen: the tree is built from the same `files` array.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushFile(row.file, fileIndex, row.depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, stickyHeaderIndices };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cumulative height of every item before `index`. Single source of truth for
|
||||||
|
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
|
||||||
|
export function sumHeightsBefore(
|
||||||
|
items: DiffFlatItem[],
|
||||||
|
index: number,
|
||||||
|
heightFor: (item: DiffFlatItem) => number,
|
||||||
|
): number {
|
||||||
|
let offset = 0;
|
||||||
|
const end = Math.min(index, items.length);
|
||||||
|
for (let i = 0; i < end; i++) {
|
||||||
|
offset += heightFor(items[i]);
|
||||||
|
}
|
||||||
|
return Math.max(0, offset);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
|
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||||
|
import { type Theme } from "@/styles/theme";
|
||||||
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
|
||||||
|
interface DiffFolderRowProps {
|
||||||
|
/** full uncompressed directory path — the collapse identity */
|
||||||
|
dirPath: string;
|
||||||
|
displayName: string;
|
||||||
|
depth: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
onToggle: (dirPath: string) => void;
|
||||||
|
onHeightChange?: (height: number) => void;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderRowPressableStyle({
|
||||||
|
hovered,
|
||||||
|
pressed,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
// Subtle background highlight on hover/press, matching the Files explorer rows
|
||||||
|
// (entryRowActive) — no opacity darken.
|
||||||
|
return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffFolderRow({
|
||||||
|
dirPath,
|
||||||
|
displayName,
|
||||||
|
depth,
|
||||||
|
collapsed,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
onToggle,
|
||||||
|
onHeightChange,
|
||||||
|
testID,
|
||||||
|
}: DiffFolderRowProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onToggle(dirPath);
|
||||||
|
}, [dirPath, onToggle]);
|
||||||
|
|
||||||
|
const handleLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
onHeightChange?.(event.nativeEvent.layout.height);
|
||||||
|
},
|
||||||
|
[onHeightChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leftStyle = useMemo(
|
||||||
|
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
|
||||||
|
[depth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} onLayout={handleLayout} testID={testID}>
|
||||||
|
<TreeIndentGuides depth={depth} />
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
style={folderRowPressableStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
|
>
|
||||||
|
<View style={leftStyle}>
|
||||||
|
<TreeChevron expanded={!collapsed} />
|
||||||
|
<Text style={styles.folderName} numberOfLines={1}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.right}>
|
||||||
|
<DiffStat additions={additions} deletions={deletions} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
folderRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingRight: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
folderRowActive: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
folderName: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
+417
-306
@@ -36,8 +36,10 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Columns2,
|
Columns2,
|
||||||
Download,
|
Download,
|
||||||
|
FolderTree,
|
||||||
GitCommitHorizontal,
|
GitCommitHorizontal,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
|
List,
|
||||||
ListChevronsDownUp,
|
ListChevronsDownUp,
|
||||||
ListChevronsUpDown,
|
ListChevronsUpDown,
|
||||||
Pilcrow,
|
Pilcrow,
|
||||||
@@ -52,6 +54,12 @@ import {
|
|||||||
type DiffLine,
|
type DiffLine,
|
||||||
type HighlightToken,
|
type HighlightToken,
|
||||||
} from "@/git/use-diff-query";
|
} from "@/git/use-diff-query";
|
||||||
|
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
|
||||||
|
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
|
||||||
|
import { DiffFolderRow } from "@/git/diff-folder-row";
|
||||||
|
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||||
|
import { SvgXml } from "react-native-svg";
|
||||||
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
@@ -191,6 +199,10 @@ function HighlightedText({
|
|||||||
interface DiffFileSectionProps {
|
interface DiffFileSectionProps {
|
||||||
file: ParsedDiffFile;
|
file: ParsedDiffFile;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
/** Tree indentation level (0 on the flat/mobile path). */
|
||||||
|
depth?: number;
|
||||||
|
/** Show the muted directory suffix (flat list); false inside the folder tree. */
|
||||||
|
showDir?: boolean;
|
||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
@@ -889,6 +901,8 @@ function SplitDiffColumn({
|
|||||||
const DiffFileHeader = memo(function DiffFileHeader({
|
const DiffFileHeader = memo(function DiffFileHeader({
|
||||||
file,
|
file,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
depth = 0,
|
||||||
|
showDir = true,
|
||||||
onToggle,
|
onToggle,
|
||||||
onHeaderHeightChange,
|
onHeaderHeightChange,
|
||||||
testID,
|
testID,
|
||||||
@@ -940,13 +954,27 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
[isExpanded],
|
[isExpanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const headerPressableStyle = useCallback(
|
||||||
|
(state: PressableStateCallbackType) =>
|
||||||
|
depth > 0
|
||||||
|
? [
|
||||||
|
fileHeaderPressableStyle(state),
|
||||||
|
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
|
||||||
|
]
|
||||||
|
: fileHeaderPressableStyle(state),
|
||||||
|
[depth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileName = file.path.split("/").pop() ?? file.path;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
||||||
|
<TreeIndentGuides depth={depth} />
|
||||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID ? `${testID}-toggle` : undefined}
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
style={fileHeaderPressableStyle}
|
style={headerPressableStyle}
|
||||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||||
cancelable={false}
|
cancelable={false}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
@@ -954,14 +982,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
onPress={toggleExpanded}
|
onPress={toggleExpanded}
|
||||||
>
|
>
|
||||||
<View style={styles.fileHeaderLeft}>
|
<View style={styles.fileHeaderLeft}>
|
||||||
|
{showDir ? null : (
|
||||||
|
<View style={styles.fileIcon}>
|
||||||
|
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<Text style={styles.fileName} numberOfLines={1}>
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
{file.path.split("/").pop()}
|
{fileName}
|
||||||
</Text>
|
|
||||||
<Text style={styles.fileDir} numberOfLines={1}>
|
|
||||||
{file.path.includes("/")
|
|
||||||
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{showDir ? (
|
||||||
|
<Text style={styles.fileDir} numberOfLines={1}>
|
||||||
|
{file.path.includes("/")
|
||||||
|
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
|
||||||
|
// stays right-aligned next to the diff stats, as in the flat list.
|
||||||
|
<View style={styles.fileDirSpacer} />
|
||||||
|
)}
|
||||||
{file.isNew && (
|
{file.isNew && (
|
||||||
<View style={styles.newBadge}>
|
<View style={styles.newBadge}>
|
||||||
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
|
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
|
||||||
@@ -1181,7 +1220,6 @@ type PressableStyleFn = (
|
|||||||
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
|
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
|
||||||
) => StyleProp<ViewStyle>;
|
) => StyleProp<ViewStyle>;
|
||||||
|
|
||||||
const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
@@ -1191,6 +1229,8 @@ const ThemedPilcrow = withUnistyles(Pilcrow);
|
|||||||
const ThemedWrapText = withUnistyles(WrapText);
|
const ThemedWrapText = withUnistyles(WrapText);
|
||||||
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
|
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
|
||||||
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
||||||
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||||
|
const ThemedList = withUnistyles(List);
|
||||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||||
const ThemedDownload = withUnistyles(Download);
|
const ThemedDownload = withUnistyles(Download);
|
||||||
const ThemedUpload = withUnistyles(Upload);
|
const ThemedUpload = withUnistyles(Upload);
|
||||||
@@ -1201,101 +1241,89 @@ const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
|||||||
const ThemedArchive = withUnistyles(Archive);
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
|
|
||||||
interface DiffLayoutToggleGroupProps {
|
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
||||||
|
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
);
|
||||||
|
const DIFF_OPTIONS_WRAP_ICON = (
|
||||||
|
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
);
|
||||||
|
|
||||||
|
interface DiffLayoutToggleProps {
|
||||||
layout: "unified" | "split";
|
layout: "unified" | "split";
|
||||||
unifiedToggleStyle: PressableStyleFn;
|
|
||||||
splitToggleStyle: PressableStyleFn;
|
|
||||||
onUnified: () => void;
|
|
||||||
onSplit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiffLayoutToggleGroup({
|
|
||||||
layout,
|
|
||||||
unifiedToggleStyle,
|
|
||||||
splitToggleStyle,
|
|
||||||
onUnified,
|
|
||||||
onSplit,
|
|
||||||
}: DiffLayoutToggleGroupProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const unifiedLabel = t("workspace.git.diff.unified");
|
|
||||||
const splitLabel = t("workspace.git.diff.split");
|
|
||||||
return (
|
|
||||||
<View style={styles.toggleButtonGroup}>
|
|
||||||
<Tooltip delayDuration={300}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={unifiedLabel}
|
|
||||||
testID="changes-layout-unified"
|
|
||||||
onPress={onUnified}
|
|
||||||
style={unifiedToggleStyle}
|
|
||||||
>
|
|
||||||
<ThemedAlignJustify
|
|
||||||
size={14}
|
|
||||||
uniProps={
|
|
||||||
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<Text style={styles.tooltipText}>{unifiedLabel}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip delayDuration={300}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={splitLabel}
|
|
||||||
testID="changes-layout-split"
|
|
||||||
onPress={onSplit}
|
|
||||||
style={splitToggleStyle}
|
|
||||||
>
|
|
||||||
<ThemedColumns2
|
|
||||||
size={14}
|
|
||||||
uniProps={
|
|
||||||
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<Text style={styles.tooltipText}>{splitLabel}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiffWhitespaceToggleProps {
|
|
||||||
hideWhitespace: boolean;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
toggleStyle: PressableStyleFn;
|
toggleStyle: PressableStyleFn;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffWhitespaceToggle({
|
function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) {
|
||||||
hideWhitespace,
|
|
||||||
isMobile,
|
|
||||||
toggleStyle,
|
|
||||||
onToggle,
|
|
||||||
}: DiffWhitespaceToggleProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const label = t("workspace.git.diff.hideWhitespace");
|
const label =
|
||||||
|
layout === "unified"
|
||||||
|
? t("workspace.git.diff.switchToSplit")
|
||||||
|
: t("workspace.git.diff.switchToUnified");
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
testID="changes-toggle-whitespace"
|
testID="changes-toggle-layout"
|
||||||
|
onPress={onToggle}
|
||||||
|
style={toggleStyle}
|
||||||
|
>
|
||||||
|
{layout === "unified" ? (
|
||||||
|
<ThemedColumns2 size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
) : (
|
||||||
|
<ThemedAlignJustify
|
||||||
|
size={isMobile ? 18 : 14}
|
||||||
|
uniProps={foregroundMutedIconColorMapping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<Text style={styles.tooltipText}>{label}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffViewModeToggleProps {
|
||||||
|
viewMode: "flat" | "tree";
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleStyle: PressableStyleFn;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffViewModeToggle({
|
||||||
|
viewMode,
|
||||||
|
isMobile,
|
||||||
|
toggleStyle,
|
||||||
|
onToggle,
|
||||||
|
}: DiffViewModeToggleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label =
|
||||||
|
viewMode === "flat"
|
||||||
|
? t("workspace.git.diff.showTreeView")
|
||||||
|
: t("workspace.git.diff.showFlatView");
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
testID="changes-toggle-view-mode"
|
||||||
style={toggleStyle}
|
style={toggleStyle}
|
||||||
onPress={onToggle}
|
onPress={onToggle}
|
||||||
>
|
>
|
||||||
<ThemedPilcrow
|
{viewMode === "flat" ? (
|
||||||
size={isMobile ? 18 : 14}
|
<ThemedFolderTree
|
||||||
uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
|
size={isMobile ? 18 : 14}
|
||||||
/>
|
uniProps={foregroundMutedIconColorMapping}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ThemedList size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
@@ -1306,50 +1334,33 @@ function DiffWhitespaceToggle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DiffFilesToolbarProps {
|
interface DiffFilesToolbarProps {
|
||||||
wrapLines: boolean;
|
allFileDiffsExpanded: boolean;
|
||||||
allExpanded: boolean;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
wrapLinesToggleStyle: PressableStyleFn;
|
|
||||||
expandAllToggleStyle: PressableStyleFn;
|
expandAllToggleStyle: PressableStyleFn;
|
||||||
onToggleWrapLines: () => void;
|
|
||||||
onToggleExpandAll: () => void;
|
onToggleExpandAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffFilesToolbar({
|
function DiffFilesToolbar({
|
||||||
wrapLines,
|
allFileDiffsExpanded,
|
||||||
allExpanded,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
wrapLinesToggleStyle,
|
|
||||||
expandAllToggleStyle,
|
expandAllToggleStyle,
|
||||||
onToggleWrapLines,
|
|
||||||
onToggleExpandAll,
|
onToggleExpandAll,
|
||||||
}: DiffFilesToolbarProps) {
|
}: DiffFilesToolbarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const wrapLinesLabel = wrapLines
|
const expandAllLabel = allFileDiffsExpanded
|
||||||
? t("workspace.git.diff.scrollLongLines")
|
|
||||||
: t("workspace.git.diff.wrapLongLines");
|
|
||||||
const expandAllLabel = allExpanded
|
|
||||||
? t("workspace.git.diff.collapseAll")
|
? t("workspace.git.diff.collapseAll")
|
||||||
: t("workspace.git.diff.expandAll");
|
: t("workspace.git.diff.expandAll");
|
||||||
return (
|
return (
|
||||||
<View style={styles.diffStatusButtons}>
|
<View style={styles.diffStatusButtons}>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
|
<Pressable
|
||||||
<ThemedWrapText
|
accessibilityRole="button"
|
||||||
size={isMobile ? 18 : 14}
|
accessibilityLabel={expandAllLabel}
|
||||||
uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
|
style={expandAllToggleStyle}
|
||||||
/>
|
onPress={onToggleExpandAll}
|
||||||
</Pressable>
|
>
|
||||||
</TooltipTrigger>
|
{allFileDiffsExpanded ? (
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<Text style={styles.tooltipText}>{wrapLinesLabel}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip delayDuration={300}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
|
|
||||||
{allExpanded ? (
|
|
||||||
<ThemedListChevronsDownUp
|
<ThemedListChevronsDownUp
|
||||||
size={isMobile ? 18 : 14}
|
size={isMobile ? 18 : 14}
|
||||||
uniProps={foregroundMutedIconColorMapping}
|
uniProps={foregroundMutedIconColorMapping}
|
||||||
@@ -1370,53 +1381,105 @@ function DiffFilesToolbar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiffRefreshButtonProps {
|
interface DiffOptionsMenuProps {
|
||||||
|
hideWhitespace: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
toggleStyle: PressableStyleFn;
|
overflowToggleStyle: PressableStyleFn;
|
||||||
onPress: () => void;
|
refreshSupported: boolean;
|
||||||
|
wrapLines: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onToggleHideWhitespace: () => void;
|
||||||
|
onToggleWrapLines: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffOptionsMenu({
|
||||||
|
hideWhitespace,
|
||||||
|
isMobile,
|
||||||
|
isRefreshing,
|
||||||
|
overflowToggleStyle,
|
||||||
|
refreshSupported,
|
||||||
|
wrapLines,
|
||||||
|
onRefresh,
|
||||||
|
onToggleHideWhitespace,
|
||||||
|
onToggleWrapLines,
|
||||||
|
}: DiffOptionsMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const whitespaceLabel = hideWhitespace
|
||||||
|
? t("workspace.git.diff.showWhitespace")
|
||||||
|
: t("workspace.git.diff.hideWhitespace");
|
||||||
|
const wrapLinesLabel = wrapLines
|
||||||
|
? t("workspace.git.diff.scrollLongLines")
|
||||||
|
: t("workspace.git.diff.wrapLongLines");
|
||||||
|
const optionsLabel = t("workspace.git.diff.options");
|
||||||
|
const refreshIcon = useMemo(
|
||||||
|
() =>
|
||||||
|
isRefreshing ? (
|
||||||
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
) : (
|
||||||
|
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
|
||||||
|
),
|
||||||
|
[isRefreshing],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={optionsLabel}
|
||||||
|
testID="changes-options-menu"
|
||||||
|
style={overflowToggleStyle}
|
||||||
|
>
|
||||||
|
<ThemedChevronDown
|
||||||
|
size={isMobile ? 18 : 14}
|
||||||
|
uniProps={foregroundMutedIconColorMapping}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<Text style={styles.tooltipText}>{optionsLabel}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end" width={240} testID="changes-options-menu-content">
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={DIFF_OPTIONS_WHITESPACE_ICON}
|
||||||
|
selected={hideWhitespace}
|
||||||
|
testID="changes-toggle-whitespace"
|
||||||
|
onSelect={onToggleHideWhitespace}
|
||||||
|
>
|
||||||
|
{whitespaceLabel}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={DIFF_OPTIONS_WRAP_ICON}
|
||||||
|
selected={wrapLines}
|
||||||
|
testID="changes-toggle-wrap-lines"
|
||||||
|
onSelect={onToggleWrapLines}
|
||||||
|
>
|
||||||
|
{wrapLinesLabel}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{refreshSupported ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={refreshIcon}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
testID="changes-refresh"
|
||||||
|
onSelect={onRefresh}
|
||||||
|
>
|
||||||
|
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
|
||||||
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const refreshLabel = t("workspace.git.diff.refresh");
|
|
||||||
return (
|
|
||||||
<Tooltip delayDuration={300}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={
|
|
||||||
isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refreshState")
|
|
||||||
}
|
|
||||||
testID="changes-refresh"
|
|
||||||
style={toggleStyle}
|
|
||||||
onPress={onPress}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
|
||||||
<View style={styles.refreshIcon}>
|
|
||||||
{isRefreshing ? (
|
|
||||||
<ThemedLoadingSpinner
|
|
||||||
size={ICON_SIZE.sm}
|
|
||||||
uniProps={foregroundMutedIconColorMapping}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<Text style={styles.tooltipText}>{refreshLabel}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiffFlatItem =
|
|
||||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
|
||||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
|
||||||
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
|
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
|
||||||
|
|
||||||
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
|
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
|
||||||
@@ -1679,30 +1742,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||||
useChangesPreferences();
|
useChangesPreferences();
|
||||||
const wrapLines = changesPreferences.wrapLines;
|
const wrapLines = changesPreferences.wrapLines;
|
||||||
|
const viewMode = changesPreferences.viewMode;
|
||||||
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
||||||
|
|
||||||
const handleToggleWrapLines = useCallback(() => {
|
const handleToggleWrapLines = useCallback(() => {
|
||||||
void updateChangesPreferences({ wrapLines: !wrapLines });
|
void updateChangesPreferences({ wrapLines: !wrapLines });
|
||||||
}, [updateChangesPreferences, wrapLines]);
|
}, [updateChangesPreferences, wrapLines]);
|
||||||
|
|
||||||
const handleLayoutChange = useCallback(
|
|
||||||
(nextLayout: "unified" | "split") => {
|
|
||||||
void updateChangesPreferences({ layout: nextLayout });
|
|
||||||
},
|
|
||||||
[updateChangesPreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleHideWhitespace = useCallback(() => {
|
const handleToggleHideWhitespace = useCallback(() => {
|
||||||
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
||||||
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
||||||
|
|
||||||
const handleLayoutUnified = useCallback(() => {
|
const handleToggleLayout = useCallback(() => {
|
||||||
handleLayoutChange("unified");
|
void updateChangesPreferences({
|
||||||
}, [handleLayoutChange]);
|
layout: changesPreferences.layout === "unified" ? "split" : "unified",
|
||||||
|
});
|
||||||
const handleLayoutSplit = useCallback(() => {
|
}, [changesPreferences.layout, updateChangesPreferences]);
|
||||||
handleLayoutChange("split");
|
|
||||||
}, [handleLayoutChange]);
|
|
||||||
|
|
||||||
const codeFontSize = appSettings.codeFontSize;
|
const codeFontSize = appSettings.codeFontSize;
|
||||||
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
|
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
|
||||||
@@ -1719,37 +1774,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
|
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
|
||||||
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
|
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
|
||||||
|
|
||||||
const unifiedToggleStyle = useMemo(
|
const layoutToggleStyle = useMemo(
|
||||||
() =>
|
() => buildToggleButtonStyle(false, styles.expandAllButton),
|
||||||
buildToggleButtonStyle(changesPreferences.layout === "unified", [
|
[],
|
||||||
styles.toggleButton,
|
|
||||||
styles.toggleButtonGroupStart,
|
|
||||||
]),
|
|
||||||
[changesPreferences.layout],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const splitToggleStyle = useMemo(
|
const viewModeToggleStyle = useMemo(
|
||||||
() =>
|
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
|
||||||
buildToggleButtonStyle(changesPreferences.layout === "split", [
|
[viewMode],
|
||||||
styles.toggleButton,
|
|
||||||
styles.toggleButtonGroupEnd,
|
|
||||||
]),
|
|
||||||
[changesPreferences.layout],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideWhitespaceToggleStyle = useMemo(
|
|
||||||
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
|
|
||||||
[changesPreferences.hideWhitespace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const wrapLinesToggleStyle = useMemo(
|
|
||||||
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
|
|
||||||
[wrapLines],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||||
|
|
||||||
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const refreshSupported = useSessionStore(
|
const refreshSupported = useSessionStore(
|
||||||
@@ -1892,7 +1929,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
(state) => state.setDiffExpandedPathsForWorkspace,
|
(state) => state.setDiffExpandedPathsForWorkspace,
|
||||||
);
|
);
|
||||||
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
|
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
|
||||||
|
// The Changes view groups files into a directory tree on every form factor,
|
||||||
|
// consistent with the Files explorer (which is also a tree on mobile).
|
||||||
|
const collapsedFoldersArray = usePanelStore((state) =>
|
||||||
|
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
|
||||||
|
);
|
||||||
|
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
|
||||||
|
(state) => state.setDiffCollapsedFoldersForWorkspace,
|
||||||
|
);
|
||||||
|
// Build the directory tree once per files-change; collapse/expand toggles only
|
||||||
|
// re-flatten it (they don't change tree shape).
|
||||||
|
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
||||||
|
// Every directory path currently in the tree — used by "collapse all folders" and to
|
||||||
|
// filter stale collapse state.
|
||||||
|
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
||||||
|
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
||||||
|
// Effective collapsed set: intersect the persisted paths with the folders actually
|
||||||
|
// present, purely at render (no store-syncing effect). A folder that left the diff and
|
||||||
|
// reappears defaults to expanded; toggles write back this pruned set, so the stored
|
||||||
|
// array stays bounded. (empty = all folders expanded, the default)
|
||||||
|
const collapsedFolders = useMemo(
|
||||||
|
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
|
||||||
|
[collapsedFoldersArray, allFolderPathSet],
|
||||||
|
);
|
||||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||||
|
const handleToggleViewMode = useCallback(() => {
|
||||||
|
const nextViewMode = viewMode === "flat" ? "tree" : "flat";
|
||||||
|
if (nextViewMode === "tree") {
|
||||||
|
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
if (workspaceStateKey) {
|
||||||
|
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void updateChangesPreferences({ viewMode: nextViewMode });
|
||||||
|
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
|
||||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
});
|
});
|
||||||
@@ -1900,26 +1970,23 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
const diffListViewportHeightRef = useRef(0);
|
const diffListViewportHeightRef = useRef(0);
|
||||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
|
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
|
||||||
|
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
|
||||||
|
// (Codex item 6) so file/folder heights can't collide by path.
|
||||||
|
const folderRowHeightRef = useRef<number>(0);
|
||||||
const defaultHeaderHeightRef = useRef<number>(44);
|
const defaultHeaderHeightRef = useRef<number>(44);
|
||||||
const [heightVersion, setHeightVersion] = useState(0);
|
const [heightVersion, setHeightVersion] = useState(0);
|
||||||
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
|
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
|
||||||
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
|
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
|
||||||
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
||||||
const items: DiffFlatItem[] = [];
|
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
|
||||||
const stickyIndices: number[] = [];
|
files,
|
||||||
for (let i = 0; i < files.length; i++) {
|
viewMode,
|
||||||
const file = files[i];
|
tree: compressedTree,
|
||||||
const isExpanded = expandedPaths.has(file.path);
|
collapsedFolders,
|
||||||
items.push({ type: "header", file, fileIndex: i, isExpanded });
|
expandedPaths,
|
||||||
if (isExpanded) {
|
});
|
||||||
stickyIndices.push(items.length - 1);
|
|
||||||
}
|
|
||||||
if (isExpanded) {
|
|
||||||
items.push({ type: "body", file, fileIndex: i });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||||
}, [expandedPaths, files]);
|
}, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]);
|
||||||
|
|
||||||
const getBodyHeightKey = useCallback(
|
const getBodyHeightKey = useCallback(
|
||||||
(file: ParsedDiffFile): string => {
|
(file: ParsedDiffFile): string => {
|
||||||
@@ -1958,6 +2025,35 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
|
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Single height source of truth for both getItemLayout and the collapse
|
||||||
|
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
|
||||||
|
// falling back to the default header height before first measurement.
|
||||||
|
const getFlatItemHeight = useCallback(
|
||||||
|
(item: DiffFlatItem): number => {
|
||||||
|
if (item.type === "folder") {
|
||||||
|
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
||||||
|
}
|
||||||
|
if (item.type === "header") {
|
||||||
|
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
|
||||||
|
}
|
||||||
|
const bodyHeightKey = getBodyHeightKey(item.file);
|
||||||
|
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
|
||||||
|
},
|
||||||
|
[estimateBodyHeight, getBodyHeightKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderRowHeightChange = useCallback((height: number) => {
|
||||||
|
if (!Number.isFinite(height) || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousHeight = folderRowHeightRef.current;
|
||||||
|
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
folderRowHeightRef.current = height;
|
||||||
|
setHeightVersion((version) => version + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
||||||
if (!Number.isFinite(height) || height <= 0) {
|
if (!Number.isFinite(height) || height <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -2013,23 +2109,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
[scrollbar],
|
[scrollbar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const computeHeaderOffset = useCallback(
|
// Offset of the first item matching `predicate`, walking the SAME flatItems
|
||||||
(path: string): number => {
|
// list getFlatItemLayout uses so folder rows are counted (single source of
|
||||||
const defaultHeaderHeight = defaultHeaderHeightRef.current;
|
// truth — Codex item 5 / finding 2).
|
||||||
let offset = 0;
|
const computeItemOffset = useCallback(
|
||||||
for (const file of files) {
|
(predicate: (item: DiffFlatItem) => boolean): number | null => {
|
||||||
if (file.path === path) {
|
const index = flatItems.findIndex(predicate);
|
||||||
break;
|
if (index < 0) {
|
||||||
}
|
return null;
|
||||||
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
|
||||||
if (expandedPaths.has(file.path)) {
|
|
||||||
const bodyHeightKey = getBodyHeightKey(file);
|
|
||||||
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Math.max(0, offset);
|
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
|
||||||
},
|
},
|
||||||
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
|
[flatItems, getFlatItemHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const computeHeaderOffset = useCallback(
|
||||||
|
(path: string): number =>
|
||||||
|
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
|
||||||
|
[computeItemOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleExpanded = useCallback(
|
const handleToggleExpanded = useCallback(
|
||||||
@@ -2067,7 +2164,41 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allExpanded = useMemo(() => {
|
const handleToggleFolder = useCallback(
|
||||||
|
(dirPath: string) => {
|
||||||
|
if (!workspaceStateKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
|
||||||
|
// Collapsing hides the subtree below this row; anchor to the folder row
|
||||||
|
// first so the viewport doesn't jump to a dead offset (Codex item 5).
|
||||||
|
if (!isCurrentlyCollapsed) {
|
||||||
|
const targetOffset = computeItemOffset(
|
||||||
|
(item) => item.type === "folder" && item.dirPath === dirPath,
|
||||||
|
);
|
||||||
|
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
||||||
|
if (
|
||||||
|
targetOffset !== null &&
|
||||||
|
shouldAnchorHeaderBeforeCollapse({
|
||||||
|
headerOffset: targetOffset,
|
||||||
|
headerHeight: folderHeight,
|
||||||
|
viewportOffset: diffListScrollOffsetRef.current,
|
||||||
|
viewportHeight: diffListViewportHeightRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCollapsed = isCurrentlyCollapsed
|
||||||
|
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
|
||||||
|
: [...collapsedFolders, dirPath];
|
||||||
|
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
|
||||||
|
},
|
||||||
|
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const allFileDiffsExpanded = useMemo(() => {
|
||||||
if (files.length === 0) return false;
|
if (files.length === 0) return false;
|
||||||
return files.every((file) => expandedPaths.has(file.path));
|
return files.every((file) => expandedPaths.has(file.path));
|
||||||
}, [expandedPaths, files]);
|
}, [expandedPaths, files]);
|
||||||
@@ -2076,7 +2207,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
if (!workspaceStateKey) {
|
if (!workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (allExpanded) {
|
if (allFileDiffsExpanded) {
|
||||||
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
||||||
} else {
|
} else {
|
||||||
setDiffExpandedPathsForWorkspace(
|
setDiffExpandedPathsForWorkspace(
|
||||||
@@ -2084,15 +2215,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
files.map((file) => file.path),
|
files.map((file) => file.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||||
|
|
||||||
const renderFlatItem = useCallback(
|
const renderFlatItem = useCallback(
|
||||||
({ item }: { item: DiffFlatItem }) => {
|
({ item }: { item: DiffFlatItem }) => {
|
||||||
|
if (item.type === "folder") {
|
||||||
|
return (
|
||||||
|
<DiffFolderRow
|
||||||
|
dirPath={item.dirPath}
|
||||||
|
displayName={item.displayName}
|
||||||
|
depth={item.depth}
|
||||||
|
collapsed={item.collapsed}
|
||||||
|
additions={item.additions}
|
||||||
|
deletions={item.deletions}
|
||||||
|
onToggle={handleToggleFolder}
|
||||||
|
onHeightChange={handleFolderRowHeightChange}
|
||||||
|
testID={`diff-folder-${item.dirPath}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (item.type === "header") {
|
if (item.type === "header") {
|
||||||
return (
|
return (
|
||||||
<DiffFileHeader
|
<DiffFileHeader
|
||||||
file={item.file}
|
file={item.file}
|
||||||
isExpanded={item.isExpanded}
|
isExpanded={item.isExpanded}
|
||||||
|
depth={item.depth}
|
||||||
|
showDir={viewMode === "flat"}
|
||||||
onToggle={handleToggleExpanded}
|
onToggle={handleToggleExpanded}
|
||||||
onHeaderHeightChange={handleHeaderHeightChange}
|
onHeaderHeightChange={handleHeaderHeightChange}
|
||||||
testID={`diff-file-${item.fileIndex}`}
|
testID={`diff-file-${item.fileIndex}`}
|
||||||
@@ -2117,40 +2265,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
diffTextMetricsStyle,
|
diffTextMetricsStyle,
|
||||||
effectiveLayout,
|
effectiveLayout,
|
||||||
handleBodyHeightChange,
|
handleBodyHeightChange,
|
||||||
|
handleFolderRowHeightChange,
|
||||||
handleHeaderHeightChange,
|
handleHeaderHeightChange,
|
||||||
handleToggleExpanded,
|
handleToggleExpanded,
|
||||||
|
handleToggleFolder,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
|
viewMode,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const flatKeyExtractor = useCallback(
|
const flatKeyExtractor = useCallback(
|
||||||
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
|
(item: DiffFlatItem) =>
|
||||||
|
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getFlatItemHeight = useCallback(
|
|
||||||
(item: DiffFlatItem): number => {
|
|
||||||
if (item.type === "header") {
|
|
||||||
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyHeightKey = getBodyHeightKey(item.file);
|
|
||||||
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
|
|
||||||
},
|
|
||||||
[estimateBodyHeight, getBodyHeightKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
|
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
|
||||||
(_data, index) => {
|
(_data, index) => {
|
||||||
let offset = 0;
|
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
|
||||||
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
|
|
||||||
const item = flatItems[itemIndex];
|
|
||||||
if (item) {
|
|
||||||
offset += getFlatItemHeight(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = flatItems[index];
|
const item = flatItems[index];
|
||||||
const length = item ? getFlatItemHeight(item) : 0;
|
const length = item ? getFlatItemHeight(item) : 0;
|
||||||
return { length, offset, index };
|
return { length, offset, index };
|
||||||
@@ -2161,17 +2294,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
const flatExtraData = useMemo(
|
const flatExtraData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
expandedPathsArray,
|
expandedPathsArray,
|
||||||
|
collapsedFoldersArray,
|
||||||
effectiveLayout,
|
effectiveLayout,
|
||||||
diffBodyTypographyKey,
|
diffBodyTypographyKey,
|
||||||
heightVersion,
|
heightVersion,
|
||||||
|
viewMode,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
expandedPathsArray,
|
expandedPathsArray,
|
||||||
|
collapsedFoldersArray,
|
||||||
effectiveLayout,
|
effectiveLayout,
|
||||||
diffBodyTypographyKey,
|
diffBodyTypographyKey,
|
||||||
heightVersion,
|
heightVersion,
|
||||||
|
viewMode,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
],
|
],
|
||||||
@@ -2301,38 +2438,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<View style={styles.diffStatusButtons}>
|
<View style={styles.diffStatusButtons}>
|
||||||
{canUseSplitLayout ? (
|
{canUseSplitLayout ? (
|
||||||
<DiffLayoutToggleGroup
|
<DiffLayoutToggle
|
||||||
layout={changesPreferences.layout}
|
layout={changesPreferences.layout}
|
||||||
unifiedToggleStyle={unifiedToggleStyle}
|
isMobile={isMobile}
|
||||||
splitToggleStyle={splitToggleStyle}
|
toggleStyle={layoutToggleStyle}
|
||||||
onUnified={handleLayoutUnified}
|
onToggle={handleToggleLayout}
|
||||||
onSplit={handleLayoutSplit}
|
/>
|
||||||
|
) : null}
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<DiffViewModeToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
isMobile={isMobile}
|
||||||
|
toggleStyle={viewModeToggleStyle}
|
||||||
|
onToggle={handleToggleViewMode}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<DiffWhitespaceToggle
|
|
||||||
hideWhitespace={changesPreferences.hideWhitespace}
|
|
||||||
isMobile={isMobile}
|
|
||||||
toggleStyle={hideWhitespaceToggleStyle}
|
|
||||||
onToggle={handleToggleHideWhitespace}
|
|
||||||
/>
|
|
||||||
{files.length > 0 ? (
|
{files.length > 0 ? (
|
||||||
<DiffFilesToolbar
|
<DiffFilesToolbar
|
||||||
wrapLines={wrapLines}
|
allFileDiffsExpanded={allFileDiffsExpanded}
|
||||||
allExpanded={allExpanded}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
wrapLinesToggleStyle={wrapLinesToggleStyle}
|
|
||||||
expandAllToggleStyle={expandAllToggleStyle}
|
expandAllToggleStyle={expandAllToggleStyle}
|
||||||
onToggleWrapLines={handleToggleWrapLines}
|
|
||||||
onToggleExpandAll={handleToggleExpandAll}
|
onToggleExpandAll={handleToggleExpandAll}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{refreshSupported ? (
|
<DiffOptionsMenu
|
||||||
<DiffRefreshButton
|
hideWhitespace={changesPreferences.hideWhitespace}
|
||||||
isRefreshing={isRefreshing}
|
isMobile={isMobile}
|
||||||
toggleStyle={refreshToggleStyle}
|
isRefreshing={isRefreshing}
|
||||||
onPress={handleRefresh}
|
overflowToggleStyle={overflowToggleStyle}
|
||||||
/>
|
refreshSupported={refreshSupported}
|
||||||
) : null}
|
wrapLines={wrapLines}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onToggleHideWhitespace={handleToggleHideWhitespace}
|
||||||
|
onToggleWrapLines={handleToggleWrapLines}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -2414,46 +2553,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
toggleButtonGroup: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
toggleButton: {
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
minWidth: {
|
|
||||||
xs: 32,
|
|
||||||
sm: 32,
|
|
||||||
md: 24,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
xs: 32,
|
|
||||||
sm: 32,
|
|
||||||
md: 24,
|
|
||||||
},
|
|
||||||
paddingHorizontal: {
|
|
||||||
xs: theme.spacing[2],
|
|
||||||
sm: theme.spacing[2],
|
|
||||||
md: theme.spacing[1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toggleButtonGroupStart: {
|
|
||||||
borderTopLeftRadius: theme.borderRadius.base,
|
|
||||||
borderBottomLeftRadius: theme.borderRadius.base,
|
|
||||||
},
|
|
||||||
toggleButtonGroupEnd: {
|
|
||||||
borderTopRightRadius: theme.borderRadius.base,
|
|
||||||
borderBottomRightRadius: theme.borderRadius.base,
|
|
||||||
},
|
|
||||||
toggleButtonSelected: {
|
toggleButtonSelected: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
refreshIcon: {
|
|
||||||
width: ICON_SIZE.md,
|
|
||||||
height: ICON_SIZE.md,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
expandAllButton: {
|
expandAllButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -2574,6 +2676,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
fileIcon: {
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
fileName: {
|
fileName: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
@@ -2588,6 +2695,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
fileDirSpacer: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
newBadge: {
|
newBadge: {
|
||||||
backgroundColor: "rgba(46, 160, 67, 0.2)",
|
backgroundColor: "rgba(46, 160, 67, 0.2)",
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDiffTree,
|
||||||
|
collectDirPaths,
|
||||||
|
compressSingleChildChains,
|
||||||
|
flattenDiffTree,
|
||||||
|
type DiffTreeRow,
|
||||||
|
} from "./diff-tree";
|
||||||
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
|
|
||||||
|
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
isNew: false,
|
||||||
|
isDeleted: false,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
hunks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Changes tree is fed the already-path-sorted diff files; build against that.
|
||||||
|
function tree(paths: Array<string | ParsedDiffFile>) {
|
||||||
|
return buildDiffTree(paths.map((p) => (typeof p === "string" ? createFile(p) : p)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compressed(paths: Array<string | ParsedDiffFile>) {
|
||||||
|
return compressSingleChildChains(tree(paths));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowLabels(rows: DiffTreeRow[]): string[] {
|
||||||
|
return rows.map((row) =>
|
||||||
|
row.kind === "folder"
|
||||||
|
? `${" ".repeat(row.depth)}[${row.displayName}]`
|
||||||
|
: `${" ".repeat(row.depth)}${row.file.path.split("/").pop()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildDiffTree", () => {
|
||||||
|
it("returns an empty root for no files", () => {
|
||||||
|
const root = tree([]);
|
||||||
|
expect(root.dirPath).toBe("");
|
||||||
|
expect(root.children).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places root-level files directly under the root with no folder", () => {
|
||||||
|
const root = tree(["README.md", "package.json"]);
|
||||||
|
expect(root.children.every((c) => c.kind === "file")).toBe(true);
|
||||||
|
expect(root.children.map((c) => (c.kind === "file" ? c.name : c.dirPath))).toEqual([
|
||||||
|
"README.md",
|
||||||
|
"package.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests files under their directory, keyed by full path", () => {
|
||||||
|
const root = tree(["src/a.ts", "src/nested/b.ts"]);
|
||||||
|
expect(root.children).toHaveLength(1);
|
||||||
|
const src = root.children[0];
|
||||||
|
expect(src.kind).toBe("dir");
|
||||||
|
if (src.kind !== "dir") return;
|
||||||
|
expect(src.dirPath).toBe("src");
|
||||||
|
const nested = src.children.find((c) => c.kind === "dir");
|
||||||
|
expect(nested?.kind === "dir" && nested.dirPath).toBe("src/nested");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders directories before files within a level, each alphabetically", () => {
|
||||||
|
// Input alpha-by-full-path puts the file 'src/a.ts' before dir 'src/z/...',
|
||||||
|
// but rendering wants dirs first.
|
||||||
|
const root = tree(["src/a.ts", "src/z/deep.ts", "src/m/mid.ts"]);
|
||||||
|
const src = root.children[0];
|
||||||
|
if (src.kind !== "dir") throw new Error("expected dir");
|
||||||
|
expect(
|
||||||
|
src.children.map((c) => (c.kind === "dir" ? `dir:${c.name}` : `file:${c.name}`)),
|
||||||
|
).toEqual(["dir:m", "dir:z", "file:a.ts"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compressSingleChildChains", () => {
|
||||||
|
it("collapses a single-child chain into one row keyed by the deepest dir", () => {
|
||||||
|
const rows = flattenDiffTree(compressed(["packages/app/src/git/diff-pane.tsx"]), new Set());
|
||||||
|
expect(rowLabels(rows)).toEqual(["[packages/app/src/git]", " diff-pane.tsx"]);
|
||||||
|
const folder = rows[0];
|
||||||
|
expect(folder.kind === "folder" && folder.dirPath).toBe("packages/app/src/git");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops compressing where a directory has multiple children", () => {
|
||||||
|
const rows = flattenDiffTree(
|
||||||
|
compressed(["packages/app/a.ts", "packages/server/b.ts"]),
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
// "packages" has two dir children, so it stays its own row.
|
||||||
|
expect(rowLabels(rows)).toEqual([
|
||||||
|
"[packages]",
|
||||||
|
" [app]",
|
||||||
|
" a.ts",
|
||||||
|
" [server]",
|
||||||
|
" b.ts",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compresses a chain that ends in a file-bearing directory", () => {
|
||||||
|
const rows = flattenDiffTree(compressed(["a/b/c/one.ts", "a/b/c/two.ts"]), new Set());
|
||||||
|
expect(rowLabels(rows)).toEqual(["[a/b/c]", " one.ts", " two.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not merge the virtual root with a single top-level dir's siblings", () => {
|
||||||
|
const rows = flattenDiffTree(compressed(["only/deep/file.ts"]), new Set());
|
||||||
|
expect(rows[0].kind === "folder" && rows[0].displayName).toBe("only/deep");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flattenDiffTree", () => {
|
||||||
|
it("expands everything when the collapsed set is empty", () => {
|
||||||
|
const rows = flattenDiffTree(compressed(["src/a.ts", "src/b.ts"]), new Set());
|
||||||
|
expect(rowLabels(rows)).toEqual(["[src]", " a.ts", " b.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits descendants of a collapsed directory but keeps the folder row", () => {
|
||||||
|
const root = compressed(["src/a.ts", "src/b.ts"]);
|
||||||
|
const rows = flattenDiffTree(root, new Set(["src"]));
|
||||||
|
expect(rowLabels(rows)).toEqual(["[src]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapsing a parent hides nested folders and files", () => {
|
||||||
|
const root = compressed(["src/deep/a.ts", "src/top.ts"]);
|
||||||
|
const rows = flattenDiffTree(root, new Set(["src"]));
|
||||||
|
expect(rowLabels(rows)).toEqual(["[src]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums descendant additions/deletions onto the folder row", () => {
|
||||||
|
const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/nested/b.ts", 5, 2)]);
|
||||||
|
const rows = flattenDiffTree(root, new Set());
|
||||||
|
const srcFolder = rows.find((r) => r.kind === "folder" && r.dirPath === "src");
|
||||||
|
expect(srcFolder).toMatchObject({ additions: 8, deletions: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports full aggregate stats even when the folder is collapsed", () => {
|
||||||
|
const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/b.ts", 4, 0)]);
|
||||||
|
const rows = flattenDiffTree(root, new Set(["src"]));
|
||||||
|
expect(rows[0]).toMatchObject({ kind: "folder", additions: 7, deletions: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectDirPaths", () => {
|
||||||
|
it("returns every logical directory path in the compressed tree", () => {
|
||||||
|
const root = compressed(["packages/app/a.ts", "packages/server/b.ts"]);
|
||||||
|
expect(collectDirPaths(root).sort()).toEqual(["packages", "packages/app", "packages/server"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user