mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8eec44c1 | ||
|
|
0b820d729a | ||
|
|
0d3b717cf3 | ||
|
|
5da6548aff | ||
|
|
d42ab91971 | ||
|
|
e528a0db06 | ||
|
|
6aba0370ae | ||
|
|
f4509fe044 | ||
|
|
3e8dce7d7c | ||
|
|
47532952f3 | ||
|
|
d7ca1b5a03 |
@@ -7,34 +7,229 @@ on:
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch, tag, or commit to build. Leave blank to build the selected workflow ref."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
publish:
|
||||
description: "Upload the APK to an existing or new GitHub release."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
description: "Release tag to rebuild and publish. Required when publish is enabled."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag || github.ref) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
build-apk:
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
artifact-name: ${{ steps.mode.outputs.artifact-name }}
|
||||
publish: ${{ steps.mode.outputs.publish }}
|
||||
release-tag: ${{ steps.mode.outputs.release-tag }}
|
||||
source-tag: ${{ steps.mode.outputs.source-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.publish && inputs.tag || inputs.ref || github.ref) || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
- name: Resolve build mode
|
||||
id: mode
|
||||
env:
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
publish=false
|
||||
release_tag=""
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
publish=true
|
||||
source_tag="${GITHUB_REF_NAME}"
|
||||
elif [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_TAG:-}" ]]; then
|
||||
echo "::error::tag is required when publish is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${INPUT_REF:-}" ]]; then
|
||||
echo "::error::ref cannot be combined with publish; the release tag is always the source."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
source_tag="${INPUT_TAG}"
|
||||
fi
|
||||
|
||||
if [[ "${publish}" == "true" ]]; then
|
||||
release_env="$(node scripts/emit-release-env.mjs --source-tag "${source_tag}")"
|
||||
printf '%s\n' "${release_env}" >> "$GITHUB_ENV"
|
||||
release_tag="$(printf '%s\n' "${release_env}" | sed -n 's/^RELEASE_TAG=//p')"
|
||||
artifact_name="paseo-${release_tag}-android.apk"
|
||||
else
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
short_sha="$(git rev-parse --short=12 HEAD)"
|
||||
artifact_name="paseo-v${package_version}-${short_sha}-android.apk"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "artifact-name=${artifact_name}"
|
||||
echo "publish=${publish}"
|
||||
echo "release-tag=${release_tag}"
|
||||
echo "source-tag=${source_tag:-}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Building ${artifact_name}; publish=${publish}"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Restore production Android credentials
|
||||
env:
|
||||
ANDROID_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_BASE64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
required=(
|
||||
ANDROID_GOOGLE_SERVICES_JSON_BASE64
|
||||
ANDROID_KEY_ALIAS
|
||||
ANDROID_KEY_PASSWORD
|
||||
ANDROID_KEYSTORE_BASE64
|
||||
ANDROID_KEYSTORE_PASSWORD
|
||||
)
|
||||
for name in "${required[@]}"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "::error::Missing required repository secret: ${name}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
google_services_file="$RUNNER_TEMP/google-services.prod.json"
|
||||
keystore_file="$RUNNER_TEMP/paseo-upload.jks"
|
||||
|
||||
printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > "$google_services_file"
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore_file"
|
||||
jq -n \
|
||||
--arg keystorePath "$keystore_file" \
|
||||
--arg keystorePassword "$ANDROID_KEYSTORE_PASSWORD" \
|
||||
--arg keyAlias "$ANDROID_KEY_ALIAS" \
|
||||
--arg keyPassword "$ANDROID_KEY_PASSWORD" \
|
||||
'{android: {keystore: {keystorePath: $keystorePath, keystorePassword: $keystorePassword, keyAlias: $keyAlias, keyPassword: $keyPassword}}}' \
|
||||
> packages/app/credentials.json
|
||||
|
||||
echo "GOOGLE_SERVICES_FILE_PROD=$google_services_file" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build signed production APK on GitHub
|
||||
env:
|
||||
APP_VARIANT: production
|
||||
EAS_BUILD: "1"
|
||||
EXPO_UPDATES_CHANNEL: production
|
||||
GRADLE_OPTS: >-
|
||||
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
-Dorg.gradle.parallel=false
|
||||
-Dorg.gradle.workers.max=1
|
||||
-Dorg.gradle.daemon=false
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
npm run build:app-deps
|
||||
npm --prefix packages/app run build:terminal-webview
|
||||
|
||||
cd packages/app
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
node -e '(async () => require("expo/node_modules/@expo/config-plugins").AndroidConfig.EasBuild.configureEasBuildAsync(process.cwd()))().catch((error) => { console.error(error); process.exitCode = 1; })'
|
||||
|
||||
cd android
|
||||
./gradlew \
|
||||
:app:assembleRelease \
|
||||
--no-daemon \
|
||||
--max-workers=1 \
|
||||
-Dorg.gradle.parallel=false \
|
||||
-x lint \
|
||||
-x lintVitalAnalyzeRelease \
|
||||
-x lintVitalRelease \
|
||||
-x generateReleaseLintModel \
|
||||
-x generateReleaseLintVitalModel
|
||||
|
||||
artifact="$RUNNER_TEMP/${{ steps.mode.outputs.artifact-name }}"
|
||||
cp app/build/outputs/apk/release/app-release.apk "$artifact"
|
||||
|
||||
apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1)"
|
||||
if [[ -z "$apksigner" ]]; then
|
||||
echo "::error::Unable to locate apksigner."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_sha256="421698bdca5bb9168c970e24539781509b5aa23fab8ab406a15b3f9c5f04c647"
|
||||
actual_sha256="$($apksigner verify --print-certs "$artifact" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
|
||||
echo "::error::APK signing certificate does not match the production key."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified production signing certificate: $actual_sha256"
|
||||
|
||||
- name: Upload APK to workflow artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.mode.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/${{ steps.mode.outputs.artifact-name }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish-apk:
|
||||
needs: build-apk
|
||||
if: needs.build-apk.outputs.publish == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ needs.build-apk.outputs.source-tag }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "${{ needs.build-apk.outputs.source-tag }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
@@ -62,71 +257,19 @@ jobs:
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download APK workflow artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Expo and EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Build Android APK on EAS
|
||||
id: eas_build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
|
||||
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
|
||||
|
||||
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
|
||||
if [ -z "$build_id" ]; then
|
||||
echo "Failed to determine EAS build ID."
|
||||
cat "$RUNNER_TEMP/eas-build.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve APK artifact URL
|
||||
id: artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
|
||||
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
|
||||
|
||||
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
|
||||
if [ -z "$artifact_url" ]; then
|
||||
echo "Failed to determine APK artifact URL."
|
||||
cat "$RUNNER_TEMP/eas-build-view.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
asset_name="paseo-${RELEASE_TAG}-android.apk"
|
||||
asset_path="$RUNNER_TEMP/$asset_name"
|
||||
|
||||
curl --fail --location --output "$asset_path" "$artifact_url"
|
||||
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
name: ${{ needs.build-apk.outputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/apk
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
gh release upload \
|
||||
"$RELEASE_TAG" \
|
||||
"$RUNNER_TEMP/apk/${{ needs.build-apk.outputs.artifact-name }}" \
|
||||
--clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
|
||||
@@ -131,9 +131,22 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Detect desktop changes
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
id: desktop_changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
desktop:
|
||||
- 'packages/desktop/**'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
@@ -147,6 +160,27 @@ jobs:
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -158,6 +158,7 @@ jobs:
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
@@ -165,6 +166,15 @@ jobs:
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-macos-${{ matrix.electron_arch }}
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -248,7 +258,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
@@ -256,6 +266,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --linux --x64)
|
||||
@@ -263,6 +274,15 @@ jobs:
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -351,6 +371,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --win --x64 --arm64)
|
||||
@@ -358,6 +379,15 @@ jobs:
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-windows-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.110 - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Kimi and other ACP agents now stay marked as running while a response is actively streaming ([#2148](https://github.com/getpaseo/paseo/pull/2148))
|
||||
|
||||
## 0.1.109 - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -52,6 +52,12 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
|
||||
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
|
||||
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
|
||||
+9
-2
@@ -137,13 +137,20 @@ adb exec-out screencap -p > screenshot.png
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (locally built, production-signed APK asset on GitHub Release). The workflow runs Expo prebuild and Gradle directly on the GitHub runner, so it does not consume an EAS cloud build.
|
||||
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores.
|
||||
|
||||
The GitHub APK workflow also supports `workflow_dispatch`:
|
||||
|
||||
- Leave `publish` disabled and optionally provide `ref` to build any branch, tag, or commit as a workflow artifact without changing a GitHub release.
|
||||
- Enable `publish` and provide an existing `tag` to rebuild that tag and upload the APK to its GitHub release. Manual publishes always build the tag itself; they cannot publish an arbitrary ref under a release tag.
|
||||
|
||||
The workflow restores the production keystore and Firebase configuration from protected GitHub repository secrets, configures the production EAS Update channel, and verifies the finished APK's signing certificate before upload. Keep the protected values synchronized with the production Android credentials in EAS. The AAB profile continues using EAS-managed credentials and store submission.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
||||
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
||||
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
||||
- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See [testing.md](testing.md#fallible-user-actions).
|
||||
- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed.
|
||||
|
||||
## Density
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@ WRONG (horizontal):
|
||||
|
||||
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
|
||||
|
||||
## Fallible user actions
|
||||
|
||||
Every user action that can fail must expose the complete operation state in the UI:
|
||||
|
||||
- **Pending:** show immediate progress and prevent accidental duplicate submissions.
|
||||
- **Success:** show the requested result, or a clear success acknowledgement when the result is not otherwise visible.
|
||||
- **Failure:** keep an actionable error visible in the same context until the user retries or dismisses it.
|
||||
|
||||
Logs, console output, and a reset button are not user feedback. Neither is a platform API unless it is verified on every supported platform: React Native Web's `Alert.alert()` is a no-op, so browser and Electron failures must use rendered app UI such as the shared alert component.
|
||||
|
||||
Every fallible action needs behavioral coverage for success and failure. RPC-backed UI should use an app Playwright test with a real browser, network, and daemon whenever feasible. The failure test must assert what the user can see and do after the failure, not an internal response, state field, or log line. Add distinct timeout or disconnect cases when they produce distinct recovery behavior.
|
||||
|
||||
## Determinism first
|
||||
|
||||
Tests must produce the same result every run:
|
||||
@@ -91,6 +103,27 @@ function createTestEmailSender() {
|
||||
|
||||
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
||||
|
||||
### Packaged desktop smoke
|
||||
|
||||
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
|
||||
|
||||
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||
|
||||
- the `paseo://app/` renderer mounts into `#root`;
|
||||
- the sandboxed preload exposes the desktop bridge;
|
||||
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
|
||||
- the bundled CLI can query that daemon and run a terminal command.
|
||||
|
||||
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
|
||||
|
||||
To exercise the smoke locally on Linux:
|
||||
|
||||
```bash
|
||||
PASEO_DESKTOP_SMOKE=1 \
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
|
||||
npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
```
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-TiKnZMEypZYb6GGj189XUeb77j6voamTs6IPJye0UyE=
|
||||
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35162,7 +35162,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36181,12 +36181,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.110",
|
||||
"@getpaseo/protocol": "0.1.110",
|
||||
"@getpaseo/server": "0.1.110",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/server": "0.1.109",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36432,10 +36432,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.110",
|
||||
"@getpaseo/relay": "0.1.110",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36446,7 +36446,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36689,7 +36689,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37585,7 +37585,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37817,7 +37817,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37830,7 +37830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38048,15 +38048,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.110",
|
||||
"@getpaseo/highlight": "0.1.110",
|
||||
"@getpaseo/protocol": "0.1.110",
|
||||
"@getpaseo/relay": "0.1.110",
|
||||
"@getpaseo/client": "0.1.109",
|
||||
"@getpaseo/highlight": "0.1.109",
|
||||
"@getpaseo/protocol": "0.1.109",
|
||||
"@getpaseo/relay": "0.1.109",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38593,7 +38593,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -130,6 +130,13 @@ export default {
|
||||
},
|
||||
updates: {
|
||||
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
...(process.env.EXPO_UPDATES_CHANNEL
|
||||
? {
|
||||
requestHeaders: {
|
||||
"expo-channel-name": process.env.EXPO_UPDATES_CHANNEL,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...buildProfile.updates,
|
||||
},
|
||||
ios: {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
fetchAgentArchivedAt,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -123,7 +122,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
|
||||
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
@@ -138,18 +137,17 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, archived.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
@@ -116,6 +119,16 @@ const test = base.extend<{
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
outdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon();
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
desktopManagedOutdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon({ desktopManaged: true });
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
|
||||
@@ -103,12 +103,6 @@ export async function fetchAgentArchivedAt(
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
export function getWorktreeRestoreFeature(client: {
|
||||
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
|
||||
}): boolean {
|
||||
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
|
||||
export interface OutdatedDaemon {
|
||||
endpoint: string;
|
||||
label: string;
|
||||
serverId: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface OutdatedDaemonReadyMessage {
|
||||
type: "ready";
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface OutdatedDaemonErrorMessage {
|
||||
type: "error";
|
||||
error: string;
|
||||
}
|
||||
|
||||
type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMessage;
|
||||
|
||||
export async function startOutdatedDaemon(options?: {
|
||||
desktopManaged?: boolean;
|
||||
}): Promise<OutdatedDaemon> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error("E2E_METRO_PORT is not set - globalSetup must run first");
|
||||
}
|
||||
|
||||
const child = fork(
|
||||
path.resolve(__dirname, "../../../server/src/server/test-utils/outdated-daemon-process.ts"),
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
E2E_METRO_PORT: metroPort,
|
||||
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
|
||||
},
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
},
|
||||
);
|
||||
const stderr: string[] = [];
|
||||
child.stderr?.on("data", (data: Buffer) => stderr.push(data.toString("utf8")));
|
||||
|
||||
try {
|
||||
const ready = await waitForDaemon(child, stderr);
|
||||
return {
|
||||
endpoint: ready.endpoint,
|
||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||
serverId: ready.serverId,
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit");
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemon(
|
||||
child: ChildProcess,
|
||||
stderr: string[],
|
||||
): Promise<OutdatedDaemonReadyMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timed out starting outdated daemon. ${stderr.join("")}`));
|
||||
}, 20_000);
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`Outdated daemon exited before startup (code ${String(code)}, signal ${String(signal)}). ${stderr.join("")}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.once("message", (message: OutdatedDaemonMessage) => {
|
||||
if (message.type === "error") {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -80,13 +80,15 @@ interface DesktopEditorTargetConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "editor" | "file-manager";
|
||||
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
|
||||
}
|
||||
|
||||
interface DesktopEditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
|
||||
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
>;
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function installFakeScheduleHost(input: {
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
workspaceRecovery: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -132,11 +132,15 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
|
||||
@@ -78,13 +78,8 @@ export async function navigateToTerminal(
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Wait for daemon connection (sidebar shows host label)
|
||||
await page
|
||||
.getByText("localhost", { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 });
|
||||
|
||||
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
||||
// Its presence is the user-visible proof that workspace and terminal state have hydrated.
|
||||
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
|
||||
// so we give it enough time for the full workspace hydration + tab creation cycle.
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Dialog } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { connectDaemonClient } from "./helpers/daemon-client-loader";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const CUSTOM_PROVIDER = {
|
||||
id: "junie",
|
||||
name: "Junie",
|
||||
} as const;
|
||||
|
||||
interface ProviderRemovalDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function removeCustomProvider(client: ProviderRemovalDaemonClient): Promise<void> {
|
||||
await client.patchDaemonConfig({ removeProviders: [CUSTOM_PROVIDER.id] });
|
||||
}
|
||||
|
||||
async function expectProviderSource(
|
||||
client: ProviderRemovalDaemonClient,
|
||||
source: "custom" | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
return snapshot.entries.find((entry) => entry.provider === CUSTOM_PROVIDER.id)?.source;
|
||||
})
|
||||
.toBe(source);
|
||||
}
|
||||
|
||||
async function clickRemoveProviderAndAcceptWarning(page: Page): Promise<Dialog> {
|
||||
let warning: Dialog | undefined;
|
||||
page.once("dialog", (dialog) => {
|
||||
warning = dialog;
|
||||
expect(dialog.message()).toContain(`Remove ${CUSTOM_PROVIDER.name}?`);
|
||||
expect(dialog.message()).toContain("This deletes the provider entry from config.json.");
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click();
|
||||
if (!warning) {
|
||||
throw new Error("Expected a provider removal confirmation dialog, but none was shown.");
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("provider removal", () => {
|
||||
test("removes a custom provider from Settings", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderSource(client, "custom");
|
||||
|
||||
await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click();
|
||||
await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible();
|
||||
await clickRemoveProviderAndAcceptWarning(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: `${CUSTOM_PROVIDER.name} provider details`,
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expectProviderSource(client, undefined);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
seedSavedSettingsHosts,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -69,6 +70,49 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("a failed remote daemon update remains visible in the host UI", async ({
|
||||
page,
|
||||
outdatedDaemon,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [outdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openHostSection(page, outdatedDaemon.serverId, "host");
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
const updateButton = page.getByTestId("host-page-update-button");
|
||||
await updateButton.click();
|
||||
|
||||
await expect(
|
||||
updateButton.filter({ hasText: /Preparing update|Downloading packages|Installing/ }),
|
||||
).toBeDisabled();
|
||||
|
||||
const updateFailure = page.getByTestId("host-page-update-error");
|
||||
await expect(updateFailure).toBeVisible();
|
||||
await expect(updateFailure).toContainText("Update failed");
|
||||
await expect(updateFailure).toContainText("Failed to update the daemon:");
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a Desktop-managed daemon explains why its update action is disabled", async ({
|
||||
page,
|
||||
desktopManagedOutdatedDaemon,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId);
|
||||
await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host");
|
||||
|
||||
const updateCard = page.getByTestId("host-page-update-card");
|
||||
await expect(updateCard).toBeVisible();
|
||||
await expect(updateCard).toContainText(
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
);
|
||||
await expect(page.getByTestId("host-page-update-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
@@ -55,7 +56,9 @@ async function expectEditorOpened(input: {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some((record) => record.editorId === input.editorId && record.path === input.path);
|
||||
.some(
|
||||
(record) => record.editorId === input.editorId && record.workspacePath === input.path,
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -75,8 +78,18 @@ test.describe("Workspace open in editor", () => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{ id: "cursor", label: "Cursor", kind: "editor" },
|
||||
{ id: "vscode", label: "VS Code", kind: "editor" },
|
||||
{
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { rename } from "node:fs/promises";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
expectWorkspaceBranch,
|
||||
openChangesPanel,
|
||||
switchBranchFromChangesPanel,
|
||||
} from "./helpers/branch-switcher";
|
||||
import {
|
||||
createIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowNotArchived,
|
||||
fetchAgentArchivedAt,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
@@ -35,6 +44,63 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Match the remote cloud-race record: workspace archived and absent, while
|
||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
const seeded = await createArchivedMissingWorktree(prefix);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowNotArchived(page, seeded.agent.title);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${seeded.agent.id}`).click();
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
async function openArchivedAgentBeforeWorkspaceHydration(page: Page, prefix: string) {
|
||||
const seeded = await createArchivedMissingWorktree(prefix);
|
||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), seeded.worktree.workspaceId);
|
||||
const openAgent = encodeURIComponent(`agent:${seeded.agent.id}`);
|
||||
|
||||
await page.goto(`${workspaceRoute}?open=${openAgent}`);
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
@@ -49,7 +115,7 @@ test.describe("Worktree restore", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({
|
||||
test("opening an active History agent navigates without restoring or unarchiving", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -69,70 +135,176 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await archiveAgentFromDaemon(client, agent.id);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// The History list is a cached react-query snapshot, so the cleared Archived
|
||||
// badge only renders after a cold refetch. Reload to remount the query fresh.
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
const row = page
|
||||
.locator('[data-testid^="agent-row-"]')
|
||||
.filter({ hasText: agent.title })
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).not.toContainText("Archived", { timeout: 30_000 });
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`),
|
||||
).toHaveCount(1);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({
|
||||
test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready");
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
await expect(
|
||||
worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId),
|
||||
).resolves.toMatchObject({ kind: "recoverable", action: "restore" });
|
||||
});
|
||||
|
||||
test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedAgentBeforeWorkspaceHydration(
|
||||
page,
|
||||
"restore-success",
|
||||
);
|
||||
await worktreeClient.fetchWorkspaces({
|
||||
subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` },
|
||||
});
|
||||
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribeSecondaryWorkspaceUpdate = () => {};
|
||||
const secondaryWorkspaceUpdate = new Promise<void>((resolve, reject) => {
|
||||
updateTimeout = setTimeout(
|
||||
() => reject(new Error("Secondary client did not receive the restored workspace")),
|
||||
30_000,
|
||||
);
|
||||
unsubscribeSecondaryWorkspaceUpdate = worktreeClient.on("workspace_update", (message) => {
|
||||
if (
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.workspace.id === worktree.workspaceId
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
|
||||
await expect(page.getByText("Restoring workspace", { exact: true })).toBeVisible();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await secondaryWorkspaceUpdate;
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
} finally {
|
||||
unsubscribeSecondaryWorkspaceUpdate();
|
||||
if (updateTimeout) {
|
||||
clearTimeout(updateTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
const switchedBranch = `restored-live-${randomUUID().slice(0, 8)}`;
|
||||
execFileSync("git", ["branch", switchedBranch], {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
stdio: "pipe",
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, worktree.workspaceName);
|
||||
await switchBranchFromChangesPanel(page, {
|
||||
from: worktree.workspaceName,
|
||||
to: switchedBranch,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
await expectWorkspaceBranch(page, switchedBranch);
|
||||
await expect(
|
||||
page.getByTestId("workspace-header-title").filter({ visible: true }).first(),
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
// Archive through the default production path the sidebar uses (no explicit
|
||||
// scope). With the restore prune fix, this default path frees the kept branch
|
||||
// so the daemon can re-check-out the worktree on restore.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
await rename(tempRepo.path, displacedProjectPath);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
||||
"The project directory needed to restore this worktree no longer exists.",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
} finally {
|
||||
await rename(displacedProjectPath, tempRepo.path);
|
||||
}
|
||||
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an unrecoverable missing workspace shows no misleading recovery action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: project.workspaceDirectory,
|
||||
workspaceId: project.workspaceId,
|
||||
title: `unrecoverable-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
await archiveLocalWorkspaceFromDaemon(worktreeClient, project.workspaceId);
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
|
||||
const displacedProjectPath = `${tempRepo.path}-missing`;
|
||||
await rename(tempRepo.path, displacedProjectPath);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
|
||||
|
||||
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The archived workspace directory no longer exists and cannot be recreated.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
} finally {
|
||||
await rename(displacedProjectPath, tempRepo.path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.110",
|
||||
"version": "0.1.109",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -108,6 +108,12 @@ function HostWorkspaceRouteContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
@@ -125,6 +131,9 @@ function HostWorkspaceRouteContent() {
|
||||
if (!hasHydratedWorkspaceLayoutStore) {
|
||||
return;
|
||||
}
|
||||
if (isOpenIntentWaitingForWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
@@ -160,6 +169,7 @@ function HostWorkspaceRouteContent() {
|
||||
setIntentConsumed(true);
|
||||
}, [
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
@@ -167,14 +177,18 @@ function HostWorkspaceRouteContent() {
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||
if (
|
||||
openValue &&
|
||||
!isOpenIntentWaitingForWorkspace &&
|
||||
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck() {
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
@@ -219,6 +233,7 @@ function WorkspaceDeck() {
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
recoveryRequested={recoveryRequested}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -230,10 +245,12 @@ function WorkspaceDeck() {
|
||||
function WorkspaceDeckEntry({
|
||||
selection,
|
||||
activeSelection,
|
||||
recoveryRequested,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
activeSelection: ActiveWorkspaceSelection;
|
||||
recoveryRequested: boolean;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
@@ -264,6 +281,7 @@ function WorkspaceDeckEntry({
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
recoveryRequested={isActive && recoveryRequested}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -21,8 +21,6 @@ import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -374,7 +372,6 @@ export function AgentList({
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||
@@ -391,35 +388,15 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const openAgent = () => {
|
||||
onAgentSelect?.();
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
pin: false,
|
||||
});
|
||||
};
|
||||
|
||||
if (agent.archivedAt) {
|
||||
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
|
||||
if (client) {
|
||||
void client
|
||||
.refreshAgent(agentId)
|
||||
.then(() => {
|
||||
openAgent();
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(serverId),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
openAgent();
|
||||
onAgentSelect?.();
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
});
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect, queryClient],
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback(
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
|
||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
|
||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||
antigravity: require("../../../assets/images/editor-apps/antigravity.png"),
|
||||
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
|
||||
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
|
||||
}
|
||||
|
||||
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
|
||||
const source = EDITOR_APP_IMAGES[editorId];
|
||||
if (!source) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
return <Image source={source} style={imageStyle} resizeMode="contain" />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Folder, SquareTerminal } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Image } from "react-native";
|
||||
|
||||
import type { DesktopOpenTargetIcon } from "@/workspace/desktop-open-targets";
|
||||
|
||||
interface EditorTargetIconProps {
|
||||
icon: DesktopOpenTargetIcon;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function EditorTargetIcon({ icon, size = 16, color }: EditorTargetIconProps) {
|
||||
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
|
||||
const imageSource = useMemo(
|
||||
() => (icon.kind === "image" ? { uri: icon.dataUrl } : undefined),
|
||||
[icon],
|
||||
);
|
||||
|
||||
if (imageSource) {
|
||||
return <Image source={imageSource} style={imageStyle} resizeMode="contain" />;
|
||||
}
|
||||
if (icon.kind === "symbol" && icon.name === "folder") {
|
||||
return <Folder size={size} color={color} />;
|
||||
}
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
@@ -951,6 +951,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
@@ -1422,6 +1425,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
skipToken,
|
||||
useQueries,
|
||||
useQuery,
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
@@ -50,6 +51,12 @@ export function useFetchQuery<
|
||||
return useQuery(fetchQueryOptions(input));
|
||||
}
|
||||
|
||||
export function useFetchQueries<TData>(
|
||||
inputs: FetchQueryInput<TData, Error, TData, QueryKey>[],
|
||||
): UseQueryResult<TData, Error>[] {
|
||||
return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) });
|
||||
}
|
||||
|
||||
function replicaQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = Error,
|
||||
|
||||
@@ -66,13 +66,15 @@ export interface DesktopEditorTargetDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "editor" | "file-manager";
|
||||
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
|
||||
}
|
||||
|
||||
export interface DesktopEditorOpenTargetInput {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface DesktopEditorBridge {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
|
||||
import {
|
||||
formatHostRuntimeSection,
|
||||
formatServerInfoSection,
|
||||
redactAppDiagnosticReport,
|
||||
} from "./app-diagnostic-report";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
@@ -41,6 +45,18 @@ function makeHost(): HostProfile {
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
|
||||
const report = formatServerInfoSection({
|
||||
status: "server_info",
|
||||
serverId: "srv-desktop-managed",
|
||||
hostname: "desktop-host.local",
|
||||
version: "0.1.108",
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
expect(report).toContain("Desktop managed: yes");
|
||||
});
|
||||
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
|
||||
@@ -7,6 +7,11 @@ interface DiagnosticEntry {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function formatDiagnosticBoolean(value: boolean | undefined): string {
|
||||
if (value === undefined) return "unknown";
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
|
||||
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
|
||||
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
|
||||
}
|
||||
@@ -77,6 +82,10 @@ export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | nu
|
||||
{ label: "Server ID", value: serverInfo.serverId },
|
||||
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
|
||||
{ label: "Version", value: serverInfo.version ?? "unknown" },
|
||||
{
|
||||
label: "Desktop managed",
|
||||
value: formatDiagnosticBoolean(serverInfo.desktopManaged),
|
||||
},
|
||||
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { dotStyles } from "./shared";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: CheckoutCommit;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onCommitPress(commit.sha);
|
||||
}, [commit.sha, onCommitPress]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID={`commit-row-${commit.shortSha}`}
|
||||
onPress={handlePress}
|
||||
style={styles.row}
|
||||
>
|
||||
<View
|
||||
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
||||
style={commit.isOnRemote ? dotStyles.dotRemote : dotStyles.dotLocal}
|
||||
/>
|
||||
<Text style={styles.shortSha}>{commit.shortSha}</Text>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{commit.subject}
|
||||
</Text>
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
shortSha: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
subject: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
caret: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { CommitRow } from "./commit-row";
|
||||
import { dotStyles } from "./shared";
|
||||
|
||||
interface CommitsSectionProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"];
|
||||
|
||||
function CommitsSectionSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
accessible
|
||||
accessibilityLabel={t("workspace.git.diff.commits.loading")}
|
||||
style={styles.skeleton}
|
||||
testID="commits-section-skeleton"
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.skeletonRow}>
|
||||
<View style={styles.skeletonDot} />
|
||||
<View style={styles.skeletonSha} />
|
||||
<View style={styles.skeletonSubject} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitsSectionContent({
|
||||
query,
|
||||
onCommitPress,
|
||||
}: {
|
||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (query.status === "error") {
|
||||
return (
|
||||
<Text style={styles.errorRow} testID="commits-section-error">
|
||||
{t("workspace.git.diff.commits.loadError")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (query.status !== "loaded") {
|
||||
return <CommitsSectionSkeleton />;
|
||||
}
|
||||
if (query.data.commits.length === 0) {
|
||||
return (
|
||||
<Text style={styles.emptyRow} testID="commits-section-empty">
|
||||
{t("workspace.git.diff.commits.empty")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{query.data.commits.map((commit) => (
|
||||
<CommitRow key={commit.sha} commit={commit} onCommitPress={onCommitPress} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreferences } = useChangesPreferences();
|
||||
const collapsed = preferences.commitsCollapsed;
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: !collapsed,
|
||||
});
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
void updatePreferences({ commitsCollapsed: !collapsed });
|
||||
}, [collapsed, updatePreferences]);
|
||||
|
||||
const headerChevronStyle = useMemo(
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
);
|
||||
|
||||
if (query.status === "unsupported") {
|
||||
return null;
|
||||
}
|
||||
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID="commits-section-header"
|
||||
onPress={handleToggleSection}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={headerChevronStyle}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
<Text style={styles.title}>{t("workspace.git.diff.commits.title")}</Text>
|
||||
{commitCount === null ? (
|
||||
<View style={styles.countSpacer} />
|
||||
) : (
|
||||
<Text
|
||||
style={styles.count}
|
||||
accessibilityLabel={t("workspace.git.diff.commits.countLabel", {
|
||||
count: commitCount,
|
||||
})}
|
||||
>
|
||||
{commitCount}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.legend}>
|
||||
<View style={dotStyles.dotLocal} />
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendLocal")}</Text>
|
||||
<View style={dotStyles.legendDotRemote} />
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerChevron: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerChevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
count: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flex: 1,
|
||||
},
|
||||
countSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
legend: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
emptyRow: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
errorRow: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.statusDanger,
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
skeleton: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
skeletonRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 20,
|
||||
},
|
||||
skeletonDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSha: {
|
||||
width: 48,
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSubject: {
|
||||
width: "55%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type CheckoutCommitFile = CheckoutCommit["files"][number];
|
||||
|
||||
export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void;
|
||||
|
||||
export const DOT_SIZE = 8;
|
||||
|
||||
// The "on remote" dot is intentionally understated: the local-only ring is the
|
||||
// state worth noticing (you still have work to push), so the remote fill is
|
||||
// dimmed toward the background rather than rendered at full-strength green.
|
||||
const REMOTE_DOT_OPACITY = 0.55;
|
||||
|
||||
/**
|
||||
* Shared local/remote commit dot styles used by both the commit rows and the
|
||||
* section legend so the two never drift.
|
||||
*
|
||||
* Semantics: a local-only commit is a hollow ring; a commit that has reached
|
||||
* the remote is a subtle (dimmed) filled green dot.
|
||||
*/
|
||||
export const dotStyles = StyleSheet.create((theme) => ({
|
||||
dotLocal: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
dotRemote: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Legend variant of the remote dot: same fill, with leading spacing so it
|
||||
// separates from the preceding "local" label in the section header legend.
|
||||
legendDotRemote: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
+511
-420
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@ interface CheckoutQueryScope {
|
||||
|
||||
type CheckoutQueryKey = readonly unknown[];
|
||||
|
||||
// A commit's file diff is immutable for a given sha+path, so every consumer
|
||||
// can share the same long-lived cache policy.
|
||||
export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000;
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
@@ -31,6 +35,19 @@ export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutCommitsQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutCommits", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutCommitFileDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
sha: string,
|
||||
path: string,
|
||||
) {
|
||||
return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const;
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
// Shared chevron used by collapsible git rows (changed-files tree, commits
|
||||
// section). Kept in one place so the muted-foreground tint and the
|
||||
// withUnistyles wrapping stay consistent across every disclosure control.
|
||||
export const ThemedChevron = withUnistyles(ChevronRight);
|
||||
|
||||
export const chevronColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCheckoutCommitsQueryResult, type CheckoutCommitsData } from "./use-commits-query";
|
||||
|
||||
const EMPTY_COMMITS: CheckoutCommitsData = { baseRef: "main", commits: [] };
|
||||
|
||||
describe("resolveCheckoutCommitsQueryResult", () => {
|
||||
it("stays idle while the collapsed section has never loaded", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: false,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: undefined,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "idle" });
|
||||
});
|
||||
|
||||
it("reports loading instead of an empty result while the first request is pending", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: undefined,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loading" });
|
||||
});
|
||||
|
||||
it("types an empty commit list as loaded data", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: EMPTY_COMMITS,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loaded", data: EMPTY_COMMITS });
|
||||
});
|
||||
|
||||
it("surfaces a cold-load error", () => {
|
||||
const error = new Error("git log failed");
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: undefined,
|
||||
isPlaceholderData: false,
|
||||
error,
|
||||
}),
|
||||
).toEqual({ status: "error", error });
|
||||
});
|
||||
|
||||
it("keeps cached data available while collapsed", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: false,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: EMPTY_COMMITS,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loaded", data: EMPTY_COMMITS });
|
||||
});
|
||||
|
||||
it("keeps previous-checkout placeholder data in loading state", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: EMPTY_COMMITS,
|
||||
isPlaceholderData: true,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loading" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { checkoutCommitsQueryKey } from "@/git/query-keys";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
// Commits ahead of base change rarely while the section is open; this keeps a
|
||||
// collapse/re-expand cycle warm without leaving the fetch result stale for long.
|
||||
const CHECKOUT_COMMITS_STALE_TIME = 30_000;
|
||||
|
||||
interface UseCheckoutCommitsQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CheckoutCommitsData {
|
||||
baseRef: string | null;
|
||||
commits: CheckoutCommit[];
|
||||
}
|
||||
|
||||
export type CheckoutCommitsQueryResult =
|
||||
| { status: "unsupported" }
|
||||
| { status: "idle" }
|
||||
| { status: "connecting" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: Error }
|
||||
| { status: "loaded"; data: CheckoutCommitsData };
|
||||
|
||||
interface ResolveCheckoutCommitsQueryResultInput {
|
||||
enabled: boolean;
|
||||
capabilityPresent: boolean;
|
||||
canFetch: boolean;
|
||||
data: CheckoutCommitsData | undefined;
|
||||
isPlaceholderData: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function resolveCheckoutCommitsQueryResult({
|
||||
enabled,
|
||||
capabilityPresent,
|
||||
canFetch,
|
||||
data,
|
||||
isPlaceholderData,
|
||||
error,
|
||||
}: ResolveCheckoutCommitsQueryResultInput): CheckoutCommitsQueryResult {
|
||||
if (!capabilityPresent) {
|
||||
return { status: "unsupported" };
|
||||
}
|
||||
if (data && !isPlaceholderData) {
|
||||
return { status: "loaded", data };
|
||||
}
|
||||
if (!enabled) {
|
||||
return { status: "idle" };
|
||||
}
|
||||
if (!canFetch) {
|
||||
return { status: "connecting" };
|
||||
}
|
||||
if (error) {
|
||||
return { status: "error", error };
|
||||
}
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
export function useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
// Single capability-detection site; downstream reads a clean load-state union.
|
||||
const capabilityPresent = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true,
|
||||
);
|
||||
|
||||
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
|
||||
const queryEnabled = enabled && capabilityPresent && canFetch;
|
||||
|
||||
const query = useFetchQuery<CheckoutCommitsData>({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.listCheckoutCommits(cwd);
|
||||
},
|
||||
enabled: queryEnabled,
|
||||
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
|
||||
dataShape: "list",
|
||||
});
|
||||
|
||||
return resolveCheckoutCommitsQueryResult({
|
||||
enabled,
|
||||
capabilityPresent,
|
||||
canFetch,
|
||||
data: query.data,
|
||||
isPlaceholderData: query.isPlaceholderData,
|
||||
error: query.error,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { resolveCommitDiffFiles } from "./use-diff-files";
|
||||
|
||||
function createCommitFile(
|
||||
overrides: Partial<CheckoutCommitFile> & { path: string },
|
||||
): CheckoutCommitFile {
|
||||
return {
|
||||
path: overrides.path,
|
||||
additions: overrides.additions ?? 0,
|
||||
deletions: overrides.deletions ?? 0,
|
||||
...(overrides.status ? { status: overrides.status } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createParsedDiffFile(
|
||||
overrides: Partial<ParsedDiffFile> & { path: string },
|
||||
): ParsedDiffFile {
|
||||
return {
|
||||
path: overrides.path,
|
||||
isNew: overrides.isNew ?? false,
|
||||
isDeleted: overrides.isDeleted ?? false,
|
||||
additions: overrides.additions ?? 1,
|
||||
deletions: overrides.deletions ?? 0,
|
||||
hunks: overrides.hunks ?? [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [{ type: "add", content: "x", tokens: [] }],
|
||||
},
|
||||
],
|
||||
status: overrides.status,
|
||||
} as ParsedDiffFile;
|
||||
}
|
||||
|
||||
describe("resolveCommitDiffFiles", () => {
|
||||
it("keeps pending commit files out of the shared view until their per-file diff resolves", () => {
|
||||
const files = [
|
||||
createCommitFile({ path: "blob.bin", status: "added" }),
|
||||
createCommitFile({ path: "src/app.ts", additions: 3, deletions: 1, status: "modified" }),
|
||||
];
|
||||
|
||||
const resolvedByPath = new Map<string, ParsedDiffFile | null | undefined>([
|
||||
["blob.bin", undefined],
|
||||
[
|
||||
"src/app.ts",
|
||||
createParsedDiffFile({
|
||||
path: "src/app.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(resolveCommitDiffFiles(files, resolvedByPath)).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "src/app.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves binary-only commit files from commit metadata when the per-file diff is null", () => {
|
||||
const files = [
|
||||
createCommitFile({ path: "blob.bin", status: "added" }),
|
||||
createCommitFile({ path: "src/app.ts", additions: 3, deletions: 1, status: "modified" }),
|
||||
];
|
||||
|
||||
const resolvedByPath = new Map<string, ParsedDiffFile | null>([
|
||||
["blob.bin", null],
|
||||
[
|
||||
"src/app.ts",
|
||||
createParsedDiffFile({
|
||||
path: "src/app.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(resolveCommitDiffFiles(files, resolvedByPath)).toEqual([
|
||||
{
|
||||
path: "blob.bin",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
status: "binary",
|
||||
},
|
||||
expect.objectContaining({
|
||||
path: "src/app.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
|
||||
import { useCheckoutCommitsQuery } from "@/git/use-commits-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
/**
|
||||
* Context needed to resolve a commit diff against a host: which daemon
|
||||
* (`serverId`), which checkout (`cwd`), and which commit (`sha`). `enabled`
|
||||
* lets the consumer pause all fetching (e.g. an inactive tab).
|
||||
*/
|
||||
export interface CommitDiffFilesContext {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
sha: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CommitDiffFilesResult {
|
||||
files: ParsedDiffFile[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
capabilityMissing: boolean;
|
||||
}
|
||||
|
||||
export function resolveCommitDiffFile(
|
||||
file: CheckoutCommitFile,
|
||||
resolved: ParsedDiffFile | null | undefined,
|
||||
): ParsedDiffFile | null {
|
||||
if (resolved !== undefined && resolved !== null) {
|
||||
return resolved;
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: file.status === "added",
|
||||
isDeleted: file.status === "deleted",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
hunks: [],
|
||||
status: "binary",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommitDiffFiles(
|
||||
files: CheckoutCommitFile[],
|
||||
resolvedByPath: ReadonlyMap<string, ParsedDiffFile | null | undefined>,
|
||||
): ParsedDiffFile[] {
|
||||
return files.flatMap((file) => {
|
||||
const resolved = resolveCommitDiffFile(file, resolvedByPath.get(file.path));
|
||||
return resolved ? [resolved] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult {
|
||||
const { serverId, cwd, sha, enabled = true } = ctx;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled });
|
||||
const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null;
|
||||
const commitFiles = useMemo(() => {
|
||||
if (!sha || !commitsData) {
|
||||
return [];
|
||||
}
|
||||
return commitsData.commits.find((commit) => commit.sha === sha)?.files ?? [];
|
||||
}, [commitsData, sha]);
|
||||
|
||||
const fileDiffsEnabled =
|
||||
enabled &&
|
||||
commitsQuery.status === "loaded" &&
|
||||
Boolean(cwd) &&
|
||||
Boolean(sha) &&
|
||||
Boolean(client) &&
|
||||
isConnected;
|
||||
const fileDiffResults = useFetchQueries(
|
||||
commitFiles.map((file) => ({
|
||||
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
|
||||
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.getCommitFileDiff(cwd, sha, file.path);
|
||||
},
|
||||
enabled: fileDiffsEnabled,
|
||||
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
dataShape: "value" as const,
|
||||
})),
|
||||
);
|
||||
const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading";
|
||||
const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null;
|
||||
const capabilityMissing = commitsQuery.status === "unsupported";
|
||||
|
||||
return useMemo<CommitDiffFilesResult>(() => {
|
||||
const resolvedByPath = new Map<string, ParsedDiffFile | null | undefined>();
|
||||
commitFiles.forEach((file, index) => {
|
||||
const fileResult = fileDiffResults[index];
|
||||
resolvedByPath.set(file.path, fileResult?.data ? fileResult.data.file : undefined);
|
||||
});
|
||||
const files = resolveCommitDiffFiles(commitFiles, resolvedByPath);
|
||||
let firstFileError: Error | null = null;
|
||||
for (const fileResult of fileDiffResults) {
|
||||
if (fileResult.error) {
|
||||
firstFileError = fileResult.error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
files,
|
||||
isLoading: commitsLoading || fileDiffResults.some((r) => r.isLoading),
|
||||
error: commitsError ?? firstFileError,
|
||||
capabilityMissing,
|
||||
};
|
||||
}, [capabilityMissing, commitFiles, commitsError, commitsLoading, fileDiffResults]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -16,7 +16,8 @@ interface UseCheckoutDiffQueryOptions {
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
|
||||
// Re-export the canonical protocol type so all consumers share one definition.
|
||||
export type { ParsedDiffFile };
|
||||
export type DiffHunk = ParsedDiffFile["hunks"][number];
|
||||
export type DiffLine = DiffHunk["lines"][number];
|
||||
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("loadChangesPreferencesFromStorage", () => {
|
||||
viewMode: "flat",
|
||||
wrapLines: true,
|
||||
hideWhitespace: false,
|
||||
commitsCollapsed: true,
|
||||
});
|
||||
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(result));
|
||||
});
|
||||
@@ -53,12 +54,39 @@ describe("loadChangesPreferencesFromStorage", () => {
|
||||
viewMode: "tree",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
commitsCollapsed: true,
|
||||
});
|
||||
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(persisted);
|
||||
expect(storage.entries.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes preferences commitsCollapsed", () => {
|
||||
it("collapses commits by default", () => {
|
||||
expect(DEFAULT_CHANGES_PREFERENCES.commitsCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips commitsCollapsed: false", async () => {
|
||||
const storage = createInMemoryKeyValueStorage({
|
||||
[CHANGES_PREFERENCES_STORAGE_KEY]: JSON.stringify({ commitsCollapsed: false }),
|
||||
});
|
||||
|
||||
const prefs = await loadChangesPreferencesFromStorage(storage);
|
||||
|
||||
expect(prefs.commitsCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to collapsed for invalid commitsCollapsed", async () => {
|
||||
const storage = createInMemoryKeyValueStorage({
|
||||
[CHANGES_PREFERENCES_STORAGE_KEY]: JSON.stringify({ commitsCollapsed: "nope" }),
|
||||
});
|
||||
|
||||
const prefs = await loadChangesPreferencesFromStorage(storage);
|
||||
|
||||
expect(prefs.commitsCollapsed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveChangesPreferences", () => {
|
||||
it("merges updates onto cached preferences and persists the result", async () => {
|
||||
const storage = createInMemoryKeyValueStorage();
|
||||
|
||||
@@ -10,6 +10,7 @@ const changesPreferencesSchema = z.object({
|
||||
viewMode: z.enum(["flat", "tree"]).optional(),
|
||||
wrapLines: z.boolean().optional(),
|
||||
hideWhitespace: z.boolean().optional(),
|
||||
commitsCollapsed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface ChangesPreferences {
|
||||
@@ -17,6 +18,7 @@ export interface ChangesPreferences {
|
||||
viewMode: "flat" | "tree";
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
commitsCollapsed: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
@@ -24,6 +26,7 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
viewMode: "flat",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
commitsCollapsed: true,
|
||||
};
|
||||
|
||||
export interface KeyValueStorage {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { EditorTargetId } from "@/workspace/editor-targets";
|
||||
|
||||
type EditorTargetId = string;
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
@@ -489,7 +489,8 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.route.hostOffline).toBe("{{hostName}} is offline");
|
||||
expect(en.workspace.route.cannotReachHost).toBe("Cannot reach {{hostName}}");
|
||||
expect(en.workspace.route.hostStatus).toBe("Host status: {{status}}");
|
||||
expect(en.workspace.route.missing).toBe("Workspace not found");
|
||||
expect(en.workspace.route.recovery.archivedTitle).toBe("Workspace archived");
|
||||
expect(en.workspace.route.recovery.unavailableTitle).toBe("Workspace unavailable");
|
||||
expect(en.message.compaction.loading).toBe("Compacting...");
|
||||
expect(en.message.compaction.auto).toBe("Context automatically compacted");
|
||||
expect(en.message.compaction.manual).toBe("Context manually compacted");
|
||||
|
||||
@@ -335,15 +335,24 @@ export const ar: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "جارٍ تحميل مساحة العمل",
|
||||
restoring: "جارٍ استعادة مساحة العمل",
|
||||
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
|
||||
connecting: "الاتصال",
|
||||
hostOffline: "{{hostName}}غير متواجد حالياً",
|
||||
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
||||
hostStatus: "حالة Host:{{status}}",
|
||||
missing: "لم يتم العثور على Workspace",
|
||||
needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
|
||||
manageHost: "إدارة المضيف",
|
||||
recovery: {
|
||||
archivedTitle: "مساحة العمل مؤرشفة",
|
||||
restoreDescription:
|
||||
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
|
||||
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
|
||||
restoreAction: "استعادة",
|
||||
unarchiveAction: "إلغاء الأرشفة",
|
||||
restoringTitle: "جارٍ استعادة مساحة العمل",
|
||||
restoringAction: "جارٍ الاستعادة...",
|
||||
unavailableTitle: "مساحة العمل غير متاحة",
|
||||
checkFailedTitle: "تعذر التحقق من مساحة العمل",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "البرامج النصية Workspace",
|
||||
@@ -733,6 +742,17 @@ export const ar: TranslationResources = {
|
||||
base: "قاعدة",
|
||||
newFile: "جديد",
|
||||
deletedFile: "تم الحذف",
|
||||
commits: {
|
||||
title: "الإيداعات",
|
||||
legendLocal: "محلي",
|
||||
legendRemote: "على المستودع البعيد",
|
||||
countLabel: "{{count}} إيداعات قبل الأساس",
|
||||
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
||||
fileDiffError: "تعذّر تحميل فروق الملف",
|
||||
loading: "جارٍ تحميل الإيداعات…",
|
||||
loadError: "تعذّر تحميل الإيداعات",
|
||||
empty: "لا توجد إيداعات قبل الأساس",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "يفتح",
|
||||
@@ -1387,6 +1407,15 @@ export const ar: TranslationResources = {
|
||||
failedToLoad: "فشل تحميل الملف",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "التغييرات",
|
||||
changesSubtitle: "فروقات شجرة العمل",
|
||||
commitSubtitle: "فروقات الالتزام",
|
||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||
empty: "لا توجد تغييرات",
|
||||
loadError: "فشل تحميل الفروقات",
|
||||
capabilityMissing: "حدّث المضيف لعرض فروقات الالتزامات.",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "خطأ",
|
||||
@@ -1859,6 +1888,8 @@ export const ar: TranslationResources = {
|
||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"يدير Paseo Desktop هذا البرنامج الخفي. حدّث Paseo Desktop على المضيف.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
@@ -1907,6 +1938,17 @@ export const ar: TranslationResources = {
|
||||
loading: "تحميل...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "غير قادر على تحديث الموفر",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "عاجز",
|
||||
loading: "تحميل",
|
||||
|
||||
@@ -334,16 +334,24 @@ export const en = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Loading workspace",
|
||||
restoring: "Restoring workspace",
|
||||
restoreFailed:
|
||||
"Couldn't restore this workspace — the directory may have been moved or deleted",
|
||||
connecting: "Connecting",
|
||||
hostOffline: "{{hostName}} is offline",
|
||||
cannotReachHost: "Cannot reach {{hostName}}",
|
||||
hostStatus: "Host status: {{status}}",
|
||||
missing: "Workspace not found",
|
||||
needsHostUpgrade: "Update your host to restore this workspace",
|
||||
manageHost: "Manage host",
|
||||
recovery: {
|
||||
archivedTitle: "Workspace archived",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.",
|
||||
unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.",
|
||||
restoreAction: "Restore",
|
||||
unarchiveAction: "Unarchive",
|
||||
restoringTitle: "Restoring workspace",
|
||||
restoringAction: "Restoring...",
|
||||
unavailableTitle: "Workspace unavailable",
|
||||
checkFailedTitle: "Couldn't check workspace",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "Workspace scripts",
|
||||
@@ -740,6 +748,17 @@ export const en = {
|
||||
base: "base",
|
||||
newFile: "New",
|
||||
deletedFile: "Deleted",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
legendLocal: "local",
|
||||
legendRemote: "on remote",
|
||||
countLabel: "{{count}} commits ahead of base",
|
||||
fileDiffEmpty: "No changes to display",
|
||||
fileDiffError: "Failed to load file diff",
|
||||
loading: "Loading commits…",
|
||||
loadError: "Failed to load commits",
|
||||
empty: "No commits ahead of base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "Open",
|
||||
@@ -1395,6 +1414,15 @@ export const en = {
|
||||
failedToLoad: "Failed to load file",
|
||||
failedToLoadPreview: "Failed to load file preview",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Changes",
|
||||
changesSubtitle: "Working tree diff",
|
||||
commitSubtitle: "Commit diff",
|
||||
directoryMissing: "Workspace directory not found.",
|
||||
empty: "No changes",
|
||||
loadError: "Failed to load diff",
|
||||
capabilityMissing: "Update the host to view commit diffs.",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
@@ -1868,6 +1896,8 @@ export const en = {
|
||||
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
@@ -1916,6 +1946,17 @@ export const en = {
|
||||
loading: "Loading...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "Unable to update provider",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Disabled",
|
||||
loading: "Loading",
|
||||
|
||||
@@ -338,16 +338,25 @@ export const es: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Cargando espacio de trabajo",
|
||||
restoring: "Restaurando espacio de trabajo",
|
||||
restoreFailed:
|
||||
"No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado",
|
||||
connecting: "Conectando",
|
||||
hostOffline: "{{hostName}}está desconectado",
|
||||
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
||||
hostStatus: "Estado deHost:{{status}}",
|
||||
missing: "Workspaceno encontrado",
|
||||
needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo",
|
||||
manageHost: "Administrar host",
|
||||
recovery: {
|
||||
archivedTitle: "Espacio de trabajo archivado",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.",
|
||||
restoreAction: "Restaurar",
|
||||
unarchiveAction: "Desarchivar",
|
||||
restoringTitle: "Restaurando espacio de trabajo",
|
||||
restoringAction: "Restaurando...",
|
||||
unavailableTitle: "Espacio de trabajo no disponible",
|
||||
checkFailedTitle: "No se pudo comprobar el espacio de trabajo",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "GuionesWorkspace",
|
||||
@@ -760,6 +769,17 @@ export const es: TranslationResources = {
|
||||
base: "base",
|
||||
newFile: "Nuevo",
|
||||
deletedFile: "Eliminado",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
legendLocal: "local",
|
||||
legendRemote: "en remoto",
|
||||
countLabel: "{{count}} commits por delante de la base",
|
||||
fileDiffEmpty: "No hay cambios para mostrar",
|
||||
fileDiffError: "Error al cargar el diff del archivo",
|
||||
loading: "Cargando commits…",
|
||||
loadError: "Error al cargar los commits",
|
||||
empty: "No hay commits por delante de la base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "Abierto",
|
||||
@@ -1426,6 +1446,15 @@ export const es: TranslationResources = {
|
||||
failedToLoad: "No se pudo cargar el archivo",
|
||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Cambios",
|
||||
changesSubtitle: "Diferencias del árbol de trabajo",
|
||||
commitSubtitle: "Diferencias del commit",
|
||||
directoryMissing: "No se encontró el directorio de Workspace.",
|
||||
empty: "Sin cambios",
|
||||
loadError: "No se pudieron cargar las diferencias",
|
||||
capabilityMissing: "Actualiza el host para ver las diferencias de los commits.",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
@@ -1906,6 +1935,8 @@ export const es: TranslationResources = {
|
||||
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Este daemon está administrado por Paseo Desktop. Actualiza Paseo Desktop en el host.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
@@ -1954,6 +1985,17 @@ export const es: TranslationResources = {
|
||||
loading: "Cargando...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "No se puede actualizar el proveedor",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Desactivado",
|
||||
loading: "Cargando",
|
||||
|
||||
@@ -338,16 +338,24 @@ export const fr: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Chargement de l'espace de travail",
|
||||
restoring: "Restauration de l'espace de travail",
|
||||
restoreFailed:
|
||||
"Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé",
|
||||
connecting: "De liaison",
|
||||
hostOffline: "{{hostName}}est hors ligne",
|
||||
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
||||
hostStatus: "StatutHost:{{status}}",
|
||||
missing: "Workspaceintrouvable",
|
||||
needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail",
|
||||
manageHost: "Gérer l'hôte",
|
||||
recovery: {
|
||||
archivedTitle: "Espace de travail archivé",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.",
|
||||
unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.",
|
||||
restoreAction: "Restaurer",
|
||||
unarchiveAction: "Désarchiver",
|
||||
restoringTitle: "Restauration de l'espace de travail",
|
||||
restoringAction: "Restauration...",
|
||||
unavailableTitle: "Espace de travail indisponible",
|
||||
checkFailedTitle: "Impossible de vérifier l'espace de travail",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "ScriptsWorkspace",
|
||||
@@ -759,6 +767,17 @@ export const fr: TranslationResources = {
|
||||
base: "base",
|
||||
newFile: "Nouveau",
|
||||
deletedFile: "Supprimé",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
legendLocal: "local",
|
||||
legendRemote: "sur le distant",
|
||||
countLabel: "{{count}} commits en avance sur la base",
|
||||
fileDiffEmpty: "Aucune modification à afficher",
|
||||
fileDiffError: "Échec du chargement du diff du fichier",
|
||||
loading: "Chargement des commits…",
|
||||
loadError: "Échec du chargement des commits",
|
||||
empty: "Aucun commit en avance sur la base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "Ouvrir",
|
||||
@@ -1429,6 +1448,15 @@ export const fr: TranslationResources = {
|
||||
failedToLoad: "Échec du chargement du fichier",
|
||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Modifications",
|
||||
changesSubtitle: "Différences de l'arbre de travail",
|
||||
commitSubtitle: "Différences du commit",
|
||||
directoryMissing: "Répertoire Workspace introuvable.",
|
||||
empty: "Aucune modification",
|
||||
loadError: "Échec du chargement des différences",
|
||||
capabilityMissing: "Mettez à jour l'hôte pour voir les différences des commits.",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "Erreur",
|
||||
@@ -1909,6 +1937,8 @@ export const fr: TranslationResources = {
|
||||
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
@@ -1957,6 +1987,17 @@ export const fr: TranslationResources = {
|
||||
loading: "Chargement...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "Impossible de mettre à jour le fournisseur",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Désactivé",
|
||||
loading: "Chargement",
|
||||
|
||||
@@ -338,16 +338,25 @@ export const ja: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "ワークスペースを読み込み中",
|
||||
restoring: "ワークスペースを復元中",
|
||||
restoreFailed:
|
||||
"このワークスペースを復元できませんでした。ディレクトリが移動または削除された可能性があります。",
|
||||
connecting: "接続中",
|
||||
hostOffline: "{{hostName}}はオフラインです",
|
||||
cannotReachHost: "{{hostName}}に到達できません",
|
||||
hostStatus: "ホストの状態: {{status}}",
|
||||
missing: "ワークスペースが見つかりません",
|
||||
needsHostUpgrade: "このワークスペースを復元するにはホストを更新してください",
|
||||
manageHost: "ホストを管理",
|
||||
recovery: {
|
||||
archivedTitle: "ワークスペースはアーカイブ済みです",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
|
||||
restoreAction: "復元",
|
||||
unarchiveAction: "アーカイブを解除",
|
||||
restoringTitle: "ワークスペースを復元中",
|
||||
restoringAction: "復元中...",
|
||||
unavailableTitle: "ワークスペースを利用できません",
|
||||
checkFailedTitle: "ワークスペースを確認できませんでした",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "ワークスペーススクリプト",
|
||||
@@ -745,6 +754,17 @@ export const ja: TranslationResources = {
|
||||
base: "ベース",
|
||||
newFile: "新規",
|
||||
deletedFile: "削除済み",
|
||||
commits: {
|
||||
title: "コミット",
|
||||
legendLocal: "ローカル",
|
||||
legendRemote: "リモート",
|
||||
countLabel: "ベースより先のコミット数: {{count}}",
|
||||
fileDiffEmpty: "表示する変更はありません",
|
||||
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
||||
loading: "コミットを読み込み中…",
|
||||
loadError: "コミットの読み込みに失敗しました",
|
||||
empty: "ベースより先のコミットはありません",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "開く",
|
||||
@@ -1404,6 +1424,15 @@ export const ja: TranslationResources = {
|
||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "変更",
|
||||
changesSubtitle: "作業ツリーの差分",
|
||||
commitSubtitle: "コミット差分",
|
||||
directoryMissing: "ワークスペースディレクトリが見つかりません。",
|
||||
empty: "変更はありません",
|
||||
loadError: "差分の読み込みに失敗しました",
|
||||
capabilityMissing: "コミット差分を表示するにはホストを更新してください。",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "エラー",
|
||||
@@ -1880,6 +1909,8 @@ export const ja: TranslationResources = {
|
||||
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"このデーモンはPaseo Desktopによって管理されています。ホスト上のPaseo Desktopを更新してください。",
|
||||
title: "デーモンを更新",
|
||||
hint: "デーモンを最新バージョンに更新して再起動します",
|
||||
confirm: "更新",
|
||||
@@ -1929,6 +1960,17 @@ export const ja: TranslationResources = {
|
||||
loading: "読み込み中...",
|
||||
addErrorTitle: "プロバイダーを追加できません",
|
||||
updateErrorTitle: "プロバイダーを更新できません",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "無効",
|
||||
loading: "読み込み中",
|
||||
|
||||
@@ -338,16 +338,25 @@ export const ptBR: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Carregando workspace",
|
||||
restoring: "Restaurando workspace",
|
||||
restoreFailed:
|
||||
"Não foi possível restaurar este workspace — o diretório pode ter sido movido ou excluído",
|
||||
connecting: "Conectando",
|
||||
hostOffline: "{{hostName}} está offline",
|
||||
cannotReachHost: "Não é possível acessar {{hostName}}",
|
||||
hostStatus: "Status do host: {{status}}",
|
||||
missing: "Workspace não encontrado",
|
||||
needsHostUpgrade: "Atualize o host para restaurar este workspace",
|
||||
manageHost: "Gerenciar host",
|
||||
recovery: {
|
||||
archivedTitle: "Workspace arquivado",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.",
|
||||
restoreAction: "Restaurar",
|
||||
unarchiveAction: "Desarquivar",
|
||||
restoringTitle: "Restaurando workspace",
|
||||
restoringAction: "Restaurando...",
|
||||
unavailableTitle: "Workspace indisponível",
|
||||
checkFailedTitle: "Não foi possível verificar o workspace",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "Scripts do workspace",
|
||||
@@ -751,6 +760,17 @@ export const ptBR: TranslationResources = {
|
||||
base: "base",
|
||||
newFile: "Novo",
|
||||
deletedFile: "Excluído",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
legendLocal: "local",
|
||||
legendRemote: "no remoto",
|
||||
countLabel: "{{count}} commits à frente da base",
|
||||
fileDiffEmpty: "Nenhuma alteração para exibir",
|
||||
fileDiffError: "Falha ao carregar diff do arquivo",
|
||||
loading: "Carregando commits…",
|
||||
loadError: "Falha ao carregar commits",
|
||||
empty: "Nenhum commit à frente da base",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "Abrir",
|
||||
@@ -1412,6 +1432,15 @@ export const ptBR: TranslationResources = {
|
||||
failedToLoad: "Falha ao carregar arquivo",
|
||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Alterações",
|
||||
changesSubtitle: "Diff da árvore de trabalho",
|
||||
commitSubtitle: "Diff do commit",
|
||||
directoryMissing: "Diretório do workspace não encontrado.",
|
||||
empty: "Nenhuma alteração",
|
||||
loadError: "Falha ao carregar diff",
|
||||
capabilityMissing: "Atualize o host para ver diffs de commits.",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "Erro",
|
||||
@@ -1890,6 +1919,8 @@ export const ptBR: TranslationResources = {
|
||||
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Este daemon é gerenciado pelo Paseo Desktop. Atualize o Paseo Desktop no host.",
|
||||
title: "Atualizar daemon",
|
||||
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
|
||||
confirm: "Atualizar",
|
||||
@@ -1939,6 +1970,17 @@ export const ptBR: TranslationResources = {
|
||||
loading: "Carregando...",
|
||||
addErrorTitle: "Não foi possível adicionar provedor",
|
||||
updateErrorTitle: "Não foi possível atualizar provedor",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Desativado",
|
||||
loading: "Carregando",
|
||||
|
||||
@@ -337,16 +337,25 @@ export const ru: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Загрузка рабочей области",
|
||||
restoring: "Восстановление рабочей области",
|
||||
restoreFailed:
|
||||
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
|
||||
connecting: "Подключение",
|
||||
hostOffline: "{{hostName}}не в сети",
|
||||
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
||||
hostStatus: "Статус Host:{{status}}",
|
||||
missing: "Workspace не найден",
|
||||
needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
|
||||
manageHost: "Управление хостом",
|
||||
recovery: {
|
||||
archivedTitle: "Рабочая область в архиве",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} была архивирована, а её worktree удалён. Восстановите ветку {{branch}}, чтобы открыть её снова.",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} находится в архиве. Разархивируйте её, чтобы открыть снова.",
|
||||
restoreAction: "Восстановить",
|
||||
unarchiveAction: "Разархивировать",
|
||||
restoringTitle: "Восстановление рабочей области",
|
||||
restoringAction: "Восстановление...",
|
||||
unavailableTitle: "Рабочая область недоступна",
|
||||
checkFailedTitle: "Не удалось проверить рабочую область",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "Скрипты Workspace",
|
||||
@@ -752,6 +761,17 @@ export const ru: TranslationResources = {
|
||||
base: "база",
|
||||
newFile: "Новый",
|
||||
deletedFile: "Удалено",
|
||||
commits: {
|
||||
title: "Коммиты",
|
||||
legendLocal: "локально",
|
||||
legendRemote: "на удалённом",
|
||||
countLabel: "{{count}} коммитов впереди базы",
|
||||
fileDiffEmpty: "Нет изменений для отображения",
|
||||
fileDiffError: "Не удалось загрузить различия файла",
|
||||
loading: "Загрузка коммитов…",
|
||||
loadError: "Не удалось загрузить коммиты",
|
||||
empty: "Нет коммитов впереди базы",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "Открыть",
|
||||
@@ -1418,6 +1438,15 @@ export const ru: TranslationResources = {
|
||||
failedToLoad: "Не удалось загрузить файл",
|
||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "Изменения",
|
||||
changesSubtitle: "Различия рабочего дерева",
|
||||
commitSubtitle: "Различия коммита",
|
||||
directoryMissing: "Каталог Workspace не найден.",
|
||||
empty: "Нет изменений",
|
||||
loadError: "Не удалось загрузить различия",
|
||||
capabilityMissing: "Обновите хост, чтобы просматривать различия коммитов.",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "Ошибка",
|
||||
@@ -1896,6 +1925,8 @@ export const ru: TranslationResources = {
|
||||
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Этот демон управляется Paseo Desktop. Обновите Paseo Desktop на хосте.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
@@ -1944,6 +1975,17 @@ export const ru: TranslationResources = {
|
||||
loading: "Загрузка...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "Невозможно обновить провайдера",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Неполноценный",
|
||||
loading: "Загрузка",
|
||||
|
||||
@@ -335,15 +335,24 @@ export const zhCN: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "正在加载 workspace",
|
||||
restoring: "正在恢复 workspace",
|
||||
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
|
||||
connecting: "正在连接",
|
||||
hostOffline: "{{hostName}} 已离线",
|
||||
cannotReachHost: "无法连接 {{hostName}}",
|
||||
hostStatus: "Host 状态:{{status}}",
|
||||
missing: "Workspace 未找到",
|
||||
needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
|
||||
manageHost: "管理 Host",
|
||||
recovery: {
|
||||
archivedTitle: "Workspace 已归档",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
|
||||
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
|
||||
restoreAction: "恢复",
|
||||
unarchiveAction: "取消归档",
|
||||
restoringTitle: "正在恢复 workspace",
|
||||
restoringAction: "正在恢复...",
|
||||
unavailableTitle: "Workspace 不可用",
|
||||
checkFailedTitle: "无法检查 workspace",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "Workspace scripts",
|
||||
@@ -728,6 +737,17 @@ export const zhCN: TranslationResources = {
|
||||
base: "base",
|
||||
newFile: "新增",
|
||||
deletedFile: "已删除",
|
||||
commits: {
|
||||
title: "提交",
|
||||
legendLocal: "本地",
|
||||
legendRemote: "已推送",
|
||||
countLabel: "领先基线 {{count}} 个提交",
|
||||
fileDiffEmpty: "没有可显示的更改",
|
||||
fileDiffError: "加载文件差异失败",
|
||||
loading: "正在加载提交…",
|
||||
loadError: "加载提交失败",
|
||||
empty: "没有领先基线的提交",
|
||||
},
|
||||
},
|
||||
openInEditor: {
|
||||
open: "打开",
|
||||
@@ -1371,6 +1391,15 @@ export const zhCN: TranslationResources = {
|
||||
failedToLoad: "加载文件失败",
|
||||
failedToLoadPreview: "加载文件预览失败",
|
||||
},
|
||||
diff: {
|
||||
changesLabel: "更改",
|
||||
changesSubtitle: "工作区差异",
|
||||
commitSubtitle: "提交差异",
|
||||
directoryMissing: "未找到 workspace 目录。",
|
||||
empty: "没有更改",
|
||||
loadError: "加载差异失败",
|
||||
capabilityMissing: "请更新主机以查看提交差异。",
|
||||
},
|
||||
},
|
||||
toolCallDetails: {
|
||||
error: "错误",
|
||||
@@ -1837,6 +1866,7 @@ export const zhCN: TranslationResources = {
|
||||
dialogFailedMessage: "无法打开重启确认对话框。",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint: "此 Daemon 由 Paseo Desktop 管理。请在 Host 上更新 Paseo Desktop。",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
@@ -1885,6 +1915,17 @@ export const zhCN: TranslationResources = {
|
||||
loading: "正在加载...",
|
||||
addErrorTitle: "无法添加 Provider",
|
||||
updateErrorTitle: "无法更新 Provider",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "已禁用",
|
||||
loading: "正在加载",
|
||||
|
||||
@@ -77,4 +77,32 @@ describe("navigateToHostWorkspaceRoute", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a workspace open intent in the POP_TO target", () => {
|
||||
const { navigationRef, dispatch } = createNavigationRef({
|
||||
key: "root-stack",
|
||||
routes: [{ key: "host-server-1", name: "h/[serverId]" }],
|
||||
});
|
||||
registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
|
||||
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a?open=agent%3Aagent-1");
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "POP_TO",
|
||||
target: "root-stack",
|
||||
payload: {
|
||||
name: "h/[serverId]",
|
||||
params: {
|
||||
serverId: "server-1",
|
||||
screen: "workspace/[workspaceId]/index",
|
||||
params: {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
open: "agent:agent-1",
|
||||
},
|
||||
pop: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react
|
||||
import { router, type Href } from "expo-router";
|
||||
import {
|
||||
encodeWorkspaceIdForPathSegment,
|
||||
getHostWorkspaceOpenParamFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
@@ -82,6 +83,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
const open = getHostWorkspaceOpenParamFromPathname(route);
|
||||
|
||||
const action: NavigationAction = {
|
||||
type: "POP_TO",
|
||||
@@ -94,6 +96,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
||||
params: {
|
||||
serverId: selection.serverId,
|
||||
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
|
||||
...(open ? { open } : {}),
|
||||
},
|
||||
// React Navigation consumes this nested hint when resolving the host child screen.
|
||||
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitCommitHorizontal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { SharedDiffView } from "@/git/diff-pane";
|
||||
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
|
||||
function CommitDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const { settings } = useAppSettings();
|
||||
const { preferences } = useChangesPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
||||
|
||||
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
|
||||
serverId,
|
||||
cwd: cwd ?? "",
|
||||
sha: target.sha,
|
||||
enabled: Boolean(cwd),
|
||||
});
|
||||
const effectiveLayout = isWeb && !isCompact ? preferences.layout : "unified";
|
||||
const displayPreferences = useMemo(
|
||||
() => ({
|
||||
layout: effectiveLayout,
|
||||
wrapLines: preferences.wrapLines,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
}),
|
||||
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
|
||||
);
|
||||
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
|
||||
|
||||
if (!cwd) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (capabilityMissing) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="commit-diff-capability-missing">
|
||||
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="commit-diff-error">
|
||||
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isLoading && files.length === 0) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="commit-diff-loading">
|
||||
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="commit-diff-empty">
|
||||
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />;
|
||||
}
|
||||
|
||||
function useCommitDiffPanelDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
label: target.sha.slice(0, 7),
|
||||
subtitle: t("panels.diff.commitSubtitle"),
|
||||
titleState: "ready",
|
||||
icon: ThemedGitCommitHorizontal,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
||||
kind: "commit_diff",
|
||||
component: CommitDiffPanel,
|
||||
useDescriptor: useCommitDiffPanelDescriptor,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { browserPanelRegistration } from "@/panels/browser-panel";
|
||||
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
@@ -20,5 +21,6 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(browserPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(commitDiffPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasDaemonReconnectedAfter } from "./daemon-reconnect";
|
||||
|
||||
describe("daemon update reconnect detection", () => {
|
||||
it("accepts a reconnect performed by the existing daemon client", () => {
|
||||
const start = {
|
||||
clientGeneration: 4,
|
||||
lastOnlineAt: "2026-07-16T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const reconnected = hasDaemonReconnectedAfter(
|
||||
{
|
||||
connectionStatus: "online",
|
||||
clientGeneration: 4,
|
||||
lastOnlineAt: "2026-07-16T10:00:05.000Z",
|
||||
},
|
||||
start,
|
||||
);
|
||||
|
||||
expect(reconnected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not accept the original connection before the daemon restarts", () => {
|
||||
const start = {
|
||||
clientGeneration: 4,
|
||||
lastOnlineAt: "2026-07-16T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const reconnected = hasDaemonReconnectedAfter(
|
||||
{
|
||||
connectionStatus: "online",
|
||||
...start,
|
||||
},
|
||||
start,
|
||||
);
|
||||
|
||||
expect(reconnected).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface DaemonConnectionMarker {
|
||||
clientGeneration: number;
|
||||
lastOnlineAt: string | null;
|
||||
}
|
||||
|
||||
interface DaemonConnectionSnapshot extends DaemonConnectionMarker {
|
||||
connectionStatus: "idle" | "connecting" | "online" | "offline" | "error";
|
||||
}
|
||||
|
||||
export function hasDaemonReconnectedAfter(
|
||||
snapshot: DaemonConnectionSnapshot | null,
|
||||
start: DaemonConnectionMarker | null,
|
||||
): boolean {
|
||||
if (snapshot?.connectionStatus !== "online") return false;
|
||||
if (!start) return true;
|
||||
return (
|
||||
snapshot.clientGeneration !== start.clientGeneration ||
|
||||
snapshot.lastOnlineAt !== start.lastOnlineAt
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { Alert as InlineAlert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect";
|
||||
import { restartDaemonFromSettings } from "./daemon-restart";
|
||||
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
@@ -365,7 +367,7 @@ export function HostSettingsPage({
|
||||
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
|
||||
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
|
||||
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
|
||||
|
||||
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
|
||||
</View>
|
||||
@@ -755,14 +757,19 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type DaemonUpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "updating"; phase: string }
|
||||
| { status: "failed"; title: string; message: string };
|
||||
|
||||
function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const daemonClient = useHostRuntimeClient(host.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const runtime = getHostRuntimeStore();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [progressPhase, setProgressPhase] = useState<string | null>(null);
|
||||
const [updateState, setUpdateState] = useState<DaemonUpdateState>({ status: "idle" });
|
||||
const isMountedRef = useRef(true);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -772,6 +779,9 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
const supportsSelfUpdate = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
|
||||
);
|
||||
const desktopManaged = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true,
|
||||
);
|
||||
|
||||
const appVersion = resolveAppVersion();
|
||||
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
@@ -788,11 +798,8 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
const hasReconnectedAfter = useCallback(
|
||||
(startGeneration: number | null) => {
|
||||
const snapshot = runtime.getSnapshot(host.serverId);
|
||||
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
|
||||
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
|
||||
},
|
||||
(startMarker: DaemonConnectionMarker | null) =>
|
||||
hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker),
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
@@ -810,27 +817,30 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (startGeneration: number | null) => {
|
||||
async (startMarker: DaemonConnectionMarker | null) => {
|
||||
const disconnectTimeoutMs = 15000;
|
||||
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
|
||||
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
|
||||
if (!hasReconnectedAfter(startMarker) && isHostConnected()) {
|
||||
await waitForCondition(
|
||||
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
|
||||
() => !isHostConnected() || hasReconnectedAfter(startMarker),
|
||||
disconnectTimeoutMs,
|
||||
);
|
||||
}
|
||||
const reconnected =
|
||||
hasReconnectedAfter(startGeneration) ||
|
||||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
|
||||
hasReconnectedAfter(startMarker) ||
|
||||
(await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs));
|
||||
if (isMountedRef.current) {
|
||||
setIsUpdating(false);
|
||||
setProgressPhase(null);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.unableToReconnectTitle"),
|
||||
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
|
||||
);
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.unableToReconnectTitle"),
|
||||
message: t("settings.host.daemon.update.unableToReconnectMessage", {
|
||||
name: host.label,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUpdateState({ status: "idle" });
|
||||
}
|
||||
},
|
||||
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
|
||||
@@ -838,17 +848,19 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.unavailableTitle"),
|
||||
t("settings.host.daemon.update.unavailableMessage"),
|
||||
);
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.unavailableTitle"),
|
||||
message: t("settings.host.daemon.update.unavailableMessage"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.offlineTitle"),
|
||||
t("settings.host.daemon.update.offlineMessage"),
|
||||
);
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.offlineTitle"),
|
||||
message: t("settings.host.daemon.update.offlineMessage"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -860,10 +872,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
destructive: false,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
|
||||
setIsUpdating(true);
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
if (!confirmed || !isMountedRef.current) return;
|
||||
const startSnapshot = runtime.getSnapshot(host.serverId);
|
||||
const startMarker = startSnapshot
|
||||
? {
|
||||
clientGeneration: startSnapshot.clientGeneration,
|
||||
lastOnlineAt: startSnapshot.lastOnlineAt,
|
||||
}
|
||||
: null;
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseStarting"),
|
||||
});
|
||||
const requestId = `settings_daemon_update_${host.serverId}`;
|
||||
|
||||
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
|
||||
@@ -871,13 +891,25 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
if (!isMountedRef.current) return;
|
||||
const { phase } = message.payload;
|
||||
if (phase === "starting")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseStarting"),
|
||||
});
|
||||
else if (phase === "downloading")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseDownloading"),
|
||||
});
|
||||
else if (phase === "installing")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseInstalling"),
|
||||
});
|
||||
else if (phase === "complete")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseComplete"),
|
||||
});
|
||||
});
|
||||
unsubscribeRef.current = unsubscribe;
|
||||
|
||||
@@ -888,18 +920,17 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
unsubscribe();
|
||||
if (!response.success) {
|
||||
if (!isMountedRef.current) return undefined;
|
||||
setIsUpdating(false);
|
||||
setProgressPhase(null);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.requestFailedTitle"),
|
||||
t("settings.host.daemon.update.requestFailedMessage", {
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: t("settings.host.daemon.update.requestFailedMessage", {
|
||||
error: response.error ?? "Unknown error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
// Update succeeded — wait for daemon to restart and reconnect
|
||||
void waitForDaemonRestart(startGeneration);
|
||||
void waitForDaemonRestart(startMarker);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -907,23 +938,24 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
unsubscribe();
|
||||
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
|
||||
if (!isMountedRef.current) return;
|
||||
setIsUpdating(false);
|
||||
setProgressPhase(null);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.requestFailedTitle"),
|
||||
t("settings.host.daemon.update.requestFailedMessage", {
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: t("settings.host.daemon.update.requestFailedMessage", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.requestFailedTitle"),
|
||||
t("settings.host.daemon.update.dialogFailedMessage"),
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: t("settings.host.daemon.update.dialogFailedMessage"),
|
||||
});
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
|
||||
|
||||
@@ -932,33 +964,46 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
|
||||
// Don't show if the daemon doesn't support self-update or versions match
|
||||
if (!supportsSelfUpdate || !hasVersionMismatch) {
|
||||
const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged);
|
||||
if (!shouldShowUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonLabel = isUpdating
|
||||
? (progressPhase ?? t("settings.host.daemon.update.updating"))
|
||||
: t("settings.host.daemon.update.confirm");
|
||||
const isUpdating = updateState.status === "updating";
|
||||
const buttonLabel = isUpdating ? updateState.phase : t("settings.host.daemon.update.confirm");
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-update-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{desktopManaged
|
||||
? t("settings.host.daemon.update.desktopManagedHint")
|
||||
: t("settings.host.daemon.update.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={updateIcon}
|
||||
onPress={handleUpdate}
|
||||
disabled={isUpdating || !daemonClient || !isConnected}
|
||||
disabled={desktopManaged || isUpdating || !daemonClient || !isConnected}
|
||||
testID="host-page-update-button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
{updateState.status === "failed" ? (
|
||||
<View style={styles.updateFailure}>
|
||||
<InlineAlert
|
||||
variant="error"
|
||||
title={updateState.title}
|
||||
description={updateState.message}
|
||||
testID="host-page-update-error"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1798,6 +1843,10 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
updateFailure: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
identityEditButton: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -97,25 +97,39 @@ vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
MoreHorizontal: icon("MoreHorizontal"),
|
||||
Trash2: icon("Trash2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
|
||||
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
|
||||
if (key === "settings.providers.statuses.disabled") return "Disabled";
|
||||
if (key === "settings.providers.statuses.available") return "Available";
|
||||
if (key === "settings.providers.statuses.loading") return "Loading";
|
||||
if (key === "settings.providers.statuses.error") return "Error";
|
||||
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
|
||||
if (key === "settings.providers.models.one") return "1 model";
|
||||
if (key === "settings.providers.models.many") return `${values?.count} models`;
|
||||
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
|
||||
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
|
||||
return key;
|
||||
},
|
||||
t: (key: string, values?: Record<string, string | number>) =>
|
||||
(
|
||||
({
|
||||
"settings.providers.providerDetails": "{{name}} provider details",
|
||||
"settings.providers.enableProvider": "Enable {{name}}",
|
||||
"settings.providers.statuses.disabled": "Disabled",
|
||||
"settings.providers.statuses.available": "Available",
|
||||
"settings.providers.statuses.loading": "Loading",
|
||||
"settings.providers.statuses.error": "Error",
|
||||
"settings.providers.statuses.notInstalled": "Not installed",
|
||||
"settings.providers.models.one": "1 model",
|
||||
"settings.providers.models.many": "{{count}} models",
|
||||
"settings.providers.addErrorTitle": "Unable to add provider",
|
||||
"settings.providers.updateErrorTitle": "Unable to update provider",
|
||||
"settings.providers.actions.menu": "{{name}} actions",
|
||||
"settings.providers.actions.remove": "Remove provider",
|
||||
"settings.providers.actions.removing": "Removing...",
|
||||
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
|
||||
"settings.providers.remove.confirmMessage":
|
||||
"This deletes the provider entry from config.json. It cannot be undone.",
|
||||
"settings.providers.remove.confirm": "Remove",
|
||||
"settings.providers.remove.errorTitle": "Unable to remove provider",
|
||||
})[key] ?? key
|
||||
)
|
||||
.replaceAll("{{name}}", String(values?.name ?? ""))
|
||||
.replaceAll("{{count}}", String(values?.count ?? "")),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -151,6 +165,68 @@ vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onPressIn,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
||||
onPressIn?: (event: { stopPropagation: () => void }) => void;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onMouseDown: (event: React.MouseEvent) => onPressIn?.(event),
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
},
|
||||
typeof children === "function"
|
||||
? children({ pressed: false, hovered: false, open: false })
|
||||
: children,
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
status,
|
||||
pendingLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
status?: "idle" | "pending" | "success";
|
||||
pendingLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
disabled: status === "pending" || status === "success",
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
},
|
||||
},
|
||||
status === "pending" ? pendingLabel : children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
@@ -190,6 +266,14 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/confirm-dialog", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { ProvidersSection } from "./providers-section";
|
||||
|
||||
const claudeEntry: ProviderSnapshotEntry = {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
@@ -16,9 +24,16 @@ import { ProviderCatalogList } from "@/components/provider-catalog-list";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||
@@ -64,9 +79,89 @@ interface ProviderRowProps {
|
||||
entry: ProviderEntry;
|
||||
enabled: boolean;
|
||||
isToggling: boolean;
|
||||
isRemoving: boolean;
|
||||
canRemove: boolean;
|
||||
isFirst: boolean;
|
||||
onPress: (providerId: string) => void;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
onRemove: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function stopPressInPropagation(event: GestureResponderEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
interface ProviderActionsMenuProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
isRemoving: boolean;
|
||||
iconSize: number;
|
||||
foregroundColor: string;
|
||||
foregroundMutedColor: string;
|
||||
dangerColor: string;
|
||||
onRemove: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function ProviderActionsMenu({
|
||||
providerId,
|
||||
providerLabel,
|
||||
isRemoving,
|
||||
iconSize,
|
||||
foregroundColor,
|
||||
foregroundMutedColor,
|
||||
dangerColor,
|
||||
onRemove,
|
||||
}: ProviderActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(providerId, providerLabel);
|
||||
}, [onRemove, providerId, providerLabel]);
|
||||
const triggerStyle = useCallback(
|
||||
({
|
||||
pressed,
|
||||
hovered,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) => [
|
||||
styles.menuButton,
|
||||
(hovered || open) && styles.menuButtonHovered,
|
||||
pressed && styles.menuButtonPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
const trashLeading = useMemo(() => <Trash2 size={16} color={dangerColor} />, [dangerColor]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isRemoving}
|
||||
hitSlop={8}
|
||||
onPressIn={stopPressInPropagation}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.actions.menu", { name: providerLabel })}
|
||||
testID={`provider-actions-${providerId}`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<MoreHorizontal
|
||||
size={iconSize}
|
||||
color={hovered || open ? foregroundColor : foregroundMutedColor}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
destructive
|
||||
leading={trashLeading}
|
||||
onSelect={handleRemove}
|
||||
status={isRemoving ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.providers.actions.removing")}
|
||||
testID={`provider-remove-${providerId}`}
|
||||
>
|
||||
{t("settings.providers.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
@@ -74,9 +169,12 @@ function ProviderRow({
|
||||
entry,
|
||||
enabled,
|
||||
isToggling,
|
||||
isRemoving,
|
||||
canRemove,
|
||||
isFirst,
|
||||
onPress,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
@@ -141,12 +239,26 @@ function ProviderRow({
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
<View style={styles.trailingControls}>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling || isRemoving}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
{canRemove ? (
|
||||
<ProviderActionsMenu
|
||||
providerId={def.id}
|
||||
providerLabel={def.label}
|
||||
isRemoving={isRemoving}
|
||||
iconSize={theme.iconSize.sm}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
dangerColor={theme.colors.statusDanger}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -203,10 +315,13 @@ export interface ProvidersSectionProps {
|
||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
|
||||
const removingProviderIdRef = useRef<string | null>(null);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
@@ -236,6 +351,38 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleRemoveProvider = useCallback(
|
||||
async (providerId: string, providerLabel: string) => {
|
||||
if (removingProviderIdRef.current) return;
|
||||
removingProviderIdRef.current = providerId;
|
||||
setRemovingProviderId(providerId);
|
||||
try {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.providers.remove.confirmTitle", { name: providerLabel }),
|
||||
message: t("settings.providers.remove.confirmMessage"),
|
||||
confirmLabel: t("settings.providers.remove.confirm"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await patchConfig({ removeProviders: [providerId] });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.providers.remove.errorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
if (removingProviderIdRef.current === providerId) {
|
||||
removingProviderIdRef.current = null;
|
||||
}
|
||||
setRemovingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
@@ -284,9 +431,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
entry={entry}
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isRemoving={removingProviderId === def.id}
|
||||
canRemove={supportsProviderRemoval && entry.source === "custom"}
|
||||
isFirst={index === 0}
|
||||
onPress={handleOpenProviderSettings}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -374,6 +524,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
trailingControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuButtonPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
}));
|
||||
|
||||
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import { EditorTargetIcon } from "@/components/icons/editor-target-icon";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -46,7 +46,7 @@ interface OpenTarget {
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
|
||||
const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
@@ -134,7 +134,9 @@ export function WorkspaceOpenInEditorButton({
|
||||
return {
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
|
||||
icon: (
|
||||
<ThemedEditorTargetIcon icon={target.icon} size={16} uniProps={mutedColorMapping} />
|
||||
),
|
||||
onOpen: () => openDesktopTarget(target.openInput),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import type { WorkspaceRouteState } from "@/screens/workspace/workspace-route-state";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface WorkspaceRouteStateActions {
|
||||
onRetryHost: () => void;
|
||||
onManageHost: () => void;
|
||||
onDismissMissingWorkspace: () => void;
|
||||
onRecoverWorkspace: () => void;
|
||||
onRetryRecoveryInspection: () => void;
|
||||
}
|
||||
|
||||
export function renderWorkspaceRouteGate(input: {
|
||||
@@ -21,8 +29,21 @@ export function renderWorkspaceRouteGate(input: {
|
||||
switch (input.state.kind) {
|
||||
case "loading":
|
||||
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||
case "restoring":
|
||||
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||
case "missing":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
titleKey="workspace.route.recovery.unavailableTitle"
|
||||
hostName={input.state.hostName}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
case "archived":
|
||||
return (
|
||||
<ArchivedWorkspaceRecovery
|
||||
state={input.state}
|
||||
onRecover={input.actions.onRecoverWorkspace}
|
||||
/>
|
||||
);
|
||||
case "needsHostUpgrade":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
@@ -39,13 +60,19 @@ export function renderWorkspaceRouteGate(input: {
|
||||
onManageHost={input.actions.onManageHost}
|
||||
/>
|
||||
);
|
||||
case "missing":
|
||||
case "recoveryUnavailable":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
titleKey={
|
||||
input.state.restoreFailed ? "workspace.route.restoreFailed" : "workspace.route.missing"
|
||||
}
|
||||
hostName={input.state.hostName}
|
||||
titleKey="workspace.route.recovery.unavailableTitle"
|
||||
description={input.state.message}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
case "recoveryInspectionFailed":
|
||||
return (
|
||||
<WorkspaceRecoveryInspectionFailed
|
||||
state={input.state}
|
||||
onRetry={input.actions.onRetryRecoveryInspection}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
@@ -69,12 +96,11 @@ function getWorkspaceHostStateTitle(
|
||||
}
|
||||
|
||||
function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.loading")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
@@ -83,16 +109,90 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRestoring({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
function ArchivedWorkspaceRecovery({
|
||||
state,
|
||||
onRecover,
|
||||
}: {
|
||||
state: Extract<WorkspaceRouteState, { kind: "archived" }>;
|
||||
onRecover: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { recovery } = state;
|
||||
const isRestoring = recovery.phase === "restoring";
|
||||
let actionLabel = t("workspace.route.recovery.unarchiveAction");
|
||||
if (recovery.recovery.action === "restore") {
|
||||
actionLabel = t("workspace.route.recovery.restoreAction");
|
||||
}
|
||||
if (recovery.phase === "failed") {
|
||||
actionLabel = t("common.actions.retry");
|
||||
}
|
||||
const description =
|
||||
recovery.recovery.action === "restore"
|
||||
? t("workspace.route.recovery.restoreDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
branch: recovery.recovery.branch,
|
||||
})
|
||||
: t("workspace.route.recovery.unarchiveDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
{isRestoring ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.restoring")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
<Text style={styles.title}>
|
||||
{isRestoring
|
||||
? t("workspace.route.recovery.restoringTitle")
|
||||
: t("workspace.route.recovery.archivedTitle")}
|
||||
</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
{recovery.error ? (
|
||||
<Text style={styles.error} testID="workspace-recovery-error">
|
||||
{recovery.error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
leftIcon={isRestoring ? undefined : RotateCw}
|
||||
onPress={onRecover}
|
||||
disabled={isRestoring}
|
||||
testID="workspace-recovery-action"
|
||||
>
|
||||
{isRestoring ? t("workspace.route.recovery.restoringAction") : actionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRecoveryInspectionFailed({
|
||||
state,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
state: Extract<WorkspaceRouteState, { kind: "recoveryInspectionFailed" }>;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.recovery.checkFailedTitle")}</Text>
|
||||
<Text style={styles.error}>{state.error}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
|
||||
{t("common.actions.back")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -107,14 +207,13 @@ function WorkspaceUnreachable({
|
||||
onRetry: () => void;
|
||||
onManageHost: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
{state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? (
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{getWorkspaceHostStateTitle(state, t)}</Text>
|
||||
@@ -155,13 +254,12 @@ function WorkspaceUnreachable({
|
||||
function WorkspaceEmptyState({
|
||||
titleKey,
|
||||
hostName,
|
||||
description,
|
||||
onDismiss,
|
||||
}: {
|
||||
titleKey:
|
||||
| "workspace.route.missing"
|
||||
| "workspace.route.restoreFailed"
|
||||
| "workspace.route.needsHostUpgrade";
|
||||
hostName: string;
|
||||
titleKey: "workspace.route.needsHostUpgrade" | "workspace.route.recovery.unavailableTitle";
|
||||
hostName?: string;
|
||||
description?: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -170,7 +268,7 @@ function WorkspaceEmptyState({
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t(titleKey)}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
<Text style={styles.description}>{description ?? hostName}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
import { resolveWorkspaceRouteState } from "./workspace-route-state";
|
||||
|
||||
function createWorkspaceDescriptor(): WorkspaceDescriptor {
|
||||
@@ -20,18 +21,38 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor {
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(input: {
|
||||
workspace?: WorkspaceDescriptor | null;
|
||||
connectionStatus?: "online" | "offline";
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
recovery?: WorkspaceRecoveryModel;
|
||||
}) {
|
||||
return resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: input.connectionStatus ?? "online",
|
||||
lastError: input.connectionStatus === "offline" ? "transport closed" : null,
|
||||
workspace: input.workspace ?? null,
|
||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces ?? true,
|
||||
recovery: input.recovery ?? { kind: "checking" },
|
||||
});
|
||||
}
|
||||
|
||||
const recoverable = {
|
||||
kind: "recoverable",
|
||||
recovery: {
|
||||
kind: "recoverable",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Feature branch",
|
||||
action: "restore",
|
||||
branch: "feature",
|
||||
},
|
||||
phase: "ready",
|
||||
error: null,
|
||||
} as const satisfies WorkspaceRecoveryModel;
|
||||
|
||||
describe("resolveWorkspaceRouteState", () => {
|
||||
it("returns unreachable when no descriptor is cached and the host is offline", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
it("keeps a missing route unreachable while its host is offline", () => {
|
||||
expect(resolve({ connectionStatus: "offline" })).toEqual({
|
||||
kind: "unreachable",
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
@@ -39,34 +60,9 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps offline routes unreachable after workspace hydration", () => {
|
||||
it("keeps a cached workspace visible while its host reconnects", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "unreachable",
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns reconnecting when the descriptor is cached and the host is offline", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
resolve({ workspace: createWorkspaceDescriptor(), connectionStatus: "offline" }),
|
||||
).toEqual({
|
||||
kind: "reconnecting",
|
||||
hostName: "Laptop",
|
||||
@@ -75,93 +71,102 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing after workspace hydration when the host is online", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
|
||||
it("waits for workspace hydration and authoritative recovery inspection", () => {
|
||||
expect(resolve({ hasHydratedWorkspaces: false })).toEqual({
|
||||
kind: "loading",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(resolve({ recovery: { kind: "checking" } })).toEqual({
|
||||
kind: "loading",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns loading before workspace hydration when the host is online", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "loading", hostName: "Laptop" });
|
||||
it("keeps an ordinary missing route distinct from an explicit recovery request", () => {
|
||||
expect(resolve({ recovery: { kind: "idle" } })).toEqual({
|
||||
kind: "missing",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
|
||||
kind: "needsHostUpgrade",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ready when the host is online and the descriptor exists", () => {
|
||||
it("renders the authoritative archived-workspace state through ready, restoring, and failed phases", () => {
|
||||
expect(resolve({ recovery: recoverable })).toEqual({
|
||||
kind: "archived",
|
||||
hostName: "Laptop",
|
||||
recovery: recoverable,
|
||||
});
|
||||
|
||||
const restoring = { ...recoverable, phase: "restoring" as const };
|
||||
expect(resolve({ recovery: restoring })).toMatchObject({
|
||||
kind: "archived",
|
||||
recovery: { phase: "restoring" },
|
||||
});
|
||||
|
||||
const failed = {
|
||||
...recoverable,
|
||||
phase: "failed" as const,
|
||||
error: "Project root is missing",
|
||||
};
|
||||
expect(resolve({ recovery: failed })).toMatchObject({
|
||||
kind: "archived",
|
||||
recovery: { phase: "failed", error: "Project root is missing" },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes unsupported and authoritatively unavailable workspaces", () => {
|
||||
expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
|
||||
kind: "needsHostUpgrade",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
resolve({
|
||||
recovery: {
|
||||
kind: "unavailable",
|
||||
recovery: {
|
||||
kind: "unavailable",
|
||||
workspaceId: "workspace-1",
|
||||
reason: "workspace_directory_missing",
|
||||
message: "The workspace directory cannot be recreated.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: "Laptop",
|
||||
message: "The workspace directory cannot be recreated.",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolve({
|
||||
recovery: {
|
||||
kind: "unsupportedAction",
|
||||
action: "repair_from_snapshot",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: "Laptop",
|
||||
message: "Update Paseo to recover this workspace.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps loading visible when the descriptor arrives, then uses it as the success transition", () => {
|
||||
const restoring = { ...recoverable, phase: "restoring" as const };
|
||||
expect(
|
||||
resolve({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
recovery: restoring,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
).toEqual({ kind: "archived", hostName: "Laptop", recovery: restoring });
|
||||
|
||||
it("returns restoring while an archived workspace restore is in flight", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
}),
|
||||
).toEqual({ kind: "restoring", hostName: "Laptop" });
|
||||
});
|
||||
|
||||
it("returns needsHostUpgrade when the daemon lacks the restore capability", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "needs-host-upgrade",
|
||||
}),
|
||||
).toEqual({ kind: "needsHostUpgrade", hostName: "Laptop" });
|
||||
});
|
||||
|
||||
it("falls back to a restore-failed missing state once the restore times out", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "failed",
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
|
||||
});
|
||||
|
||||
it("resolves to ready when the descriptor arrives even while restoring", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
resolve({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
recovery: recoverable,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
|
||||
export type WorkspaceRouteState =
|
||||
| { kind: "ready" }
|
||||
@@ -16,9 +17,15 @@ export type WorkspaceRouteState =
|
||||
lastError: string | null;
|
||||
}
|
||||
| { kind: "loading"; hostName: string }
|
||||
| { kind: "restoring"; hostName: string }
|
||||
| { kind: "missing"; hostName: string }
|
||||
| { kind: "needsHostUpgrade"; hostName: string }
|
||||
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
||||
| {
|
||||
kind: "archived";
|
||||
hostName: string;
|
||||
recovery: Extract<WorkspaceRecoveryModel, { kind: "recoverable" }>;
|
||||
}
|
||||
| { kind: "recoveryUnavailable"; hostName: string; message: string }
|
||||
| { kind: "recoveryInspectionFailed"; hostName: string; error: string };
|
||||
|
||||
export function resolveWorkspaceRouteState(input: {
|
||||
hostName: string;
|
||||
@@ -26,10 +33,13 @@ export function resolveWorkspaceRouteState(input: {
|
||||
lastError: string | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null;
|
||||
recovery: WorkspaceRecoveryModel;
|
||||
}): WorkspaceRouteState {
|
||||
if (input.workspace) {
|
||||
if (input.connectionStatus === "online") {
|
||||
if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") {
|
||||
return { kind: "archived", hostName: input.hostName, recovery: input.recovery };
|
||||
}
|
||||
return { kind: "ready" };
|
||||
}
|
||||
|
||||
@@ -41,30 +51,46 @@ export function resolveWorkspaceRouteState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (input.connectionStatus === "online") {
|
||||
if (input.restoreStatus === "restoring") {
|
||||
return { kind: "restoring", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.restoreStatus === "needs-host-upgrade") {
|
||||
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.hasHydratedWorkspaces) {
|
||||
return {
|
||||
kind: "missing",
|
||||
hostName: input.hostName,
|
||||
restoreFailed: input.restoreStatus === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
if (input.connectionStatus !== "online") {
|
||||
return {
|
||||
kind: "unreachable",
|
||||
hostName: input.hostName,
|
||||
connectionStatus: input.connectionStatus,
|
||||
lastError: input.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unreachable",
|
||||
hostName: input.hostName,
|
||||
connectionStatus: input.connectionStatus,
|
||||
lastError: input.lastError,
|
||||
};
|
||||
if (!input.hasHydratedWorkspaces) {
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
}
|
||||
if (input.recovery.kind === "idle") {
|
||||
return { kind: "missing", hostName: input.hostName };
|
||||
}
|
||||
|
||||
switch (input.recovery.kind) {
|
||||
case "checking":
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
case "needsHostUpgrade":
|
||||
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||
case "recoverable":
|
||||
return { kind: "archived", hostName: input.hostName, recovery: input.recovery };
|
||||
case "unavailable":
|
||||
return {
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: input.hostName,
|
||||
message: input.recovery.recovery.message,
|
||||
};
|
||||
case "unsupportedAction":
|
||||
return {
|
||||
kind: "recoveryUnavailable",
|
||||
hostName: input.hostName,
|
||||
message: "Update Paseo to recover this workspace.",
|
||||
};
|
||||
case "inspectionFailed":
|
||||
return {
|
||||
kind: "recoveryInspectionFailed",
|
||||
hostName: input.hostName,
|
||||
error: input.recovery.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +71,7 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
useSessionStore,
|
||||
useWorkspaceRestoreStatus,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
@@ -144,6 +140,8 @@ import {
|
||||
type WorkspaceRouteState,
|
||||
} from "@/screens/workspace/workspace-route-state";
|
||||
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
|
||||
import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recovery";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
@@ -283,10 +281,12 @@ interface WorkspaceScreenProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isRouteFocused?: boolean;
|
||||
recoveryRequested?: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||
isRouteFocused: boolean;
|
||||
recoveryRequested: boolean;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -351,6 +351,9 @@ function getFallbackTabOptionLabel(
|
||||
if (tab.target.kind === "file") {
|
||||
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
||||
}
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return tab.target.sha.slice(0, 7);
|
||||
}
|
||||
return labels.agent;
|
||||
}
|
||||
|
||||
@@ -382,6 +385,9 @@ function getFallbackTabOptionDescription(
|
||||
if (tab.target.kind === "provider_subagent") {
|
||||
return labels.agent;
|
||||
}
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return tab.target.sha.slice(0, 7);
|
||||
}
|
||||
return tab.target.path;
|
||||
}
|
||||
|
||||
@@ -896,6 +902,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
return (
|
||||
@@ -903,6 +910,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1462,9 +1470,9 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
|
||||
|
||||
function useResolvedWorkspaceRouteState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
recovery: WorkspaceRecoveryModel;
|
||||
}): WorkspaceRouteState {
|
||||
const hosts = useHosts();
|
||||
const host = useMemo(
|
||||
@@ -1473,8 +1481,6 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
);
|
||||
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
|
||||
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
|
||||
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
resolveWorkspaceRouteState({
|
||||
@@ -1483,7 +1489,7 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
lastError: hostSnapshot?.lastError ?? null,
|
||||
workspace: input.workspace,
|
||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
recovery: input.recovery,
|
||||
}),
|
||||
[
|
||||
hostName,
|
||||
@@ -1491,11 +1497,19 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
hostSnapshot?.lastError,
|
||||
input.workspace,
|
||||
input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
input.recovery,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces: boolean,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
recoveryRequested: boolean,
|
||||
): boolean {
|
||||
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
|
||||
}
|
||||
|
||||
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
@@ -1691,6 +1705,7 @@ function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1752,6 +1767,15 @@ function WorkspaceScreenContent({
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
enabled: shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces,
|
||||
workspaceDescriptor,
|
||||
recoveryRequested,
|
||||
),
|
||||
});
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
@@ -1827,9 +1851,9 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
recovery: workspaceRecovery.state,
|
||||
});
|
||||
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
||||
isCheckoutStatusLoading,
|
||||
@@ -3291,6 +3315,8 @@ function WorkspaceScreenContent({
|
||||
onRetryHost: handleRetryHost,
|
||||
onManageHost: handleManageHost,
|
||||
onDismissMissingWorkspace: handleDismissMissingWorkspace,
|
||||
onRecoverWorkspace: workspaceRecovery.restore,
|
||||
onRetryRecoveryInspection: workspaceRecovery.retryInspection,
|
||||
},
|
||||
});
|
||||
const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({
|
||||
|
||||
@@ -141,6 +141,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "provider_subagent") {
|
||||
return `workspace-provider-subagent-close-${tab.target.subagentId}`;
|
||||
}
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
navigateToLastWorkspace as navigateToLastWorkspacePure,
|
||||
navigateToWorkspace as navigateToWorkspacePure,
|
||||
parseActiveWorkspaceSelection,
|
||||
type NavigateToWorkspaceInput,
|
||||
type NavigateToWorkspaceDeps,
|
||||
type NavigateToWorkspaceInput,
|
||||
} from "./navigation";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
@@ -134,6 +134,24 @@ describe("workspace navigation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("defers an agent tab until a missing workspace is recovered", () => {
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map(),
|
||||
});
|
||||
|
||||
navigateToWorkspace(
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
const selection = parseActiveWorkspaceSelection({
|
||||
pathname: "/h/server-1/workspace/workspace-a",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
import {
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -82,14 +83,16 @@ export function navigateToWorkspace(
|
||||
input: NavigateToWorkspaceInput,
|
||||
deps: NavigateToWorkspaceDeps,
|
||||
): string {
|
||||
const workspaces = deps.getSessionWorkspaces(input.serverId);
|
||||
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.target) {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
if (resolvedWorkspaceId || input.target.kind !== "agent") {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
}
|
||||
} else {
|
||||
const workspaces = deps.getSessionWorkspaces(input.serverId);
|
||||
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(input.serverId)).filter(
|
||||
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
|
||||
@@ -104,7 +107,14 @@ export function navigateToWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
const route =
|
||||
input.target?.kind === "agent" && !resolvedWorkspaceId
|
||||
? buildHostWorkspaceOpenRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
`agent:${input.target.agentId}`,
|
||||
)
|
||||
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
|
||||
deps.navigateToRoute(route);
|
||||
return route;
|
||||
|
||||
@@ -364,51 +364,6 @@ describe("mergeWorkspaces", () => {
|
||||
expect(after.workspaces).not.toBe(before.workspaces);
|
||||
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a pending restore status when the matching descriptor lands", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe(
|
||||
"restoring",
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaceRestoreStatus", () => {
|
||||
it("marks restoring then failed while the workspace is still absent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed");
|
||||
});
|
||||
|
||||
it("ignores a late failed once the descriptor has landed (no-op)", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores failed when no restore is in flight", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaces", () => {
|
||||
|
||||
@@ -306,6 +306,7 @@ export interface DaemonServerInfo {
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
version: string | null;
|
||||
desktopManaged?: boolean;
|
||||
capabilities?: ServerCapabilities;
|
||||
features?: ServerInfoStatusPayload["features"];
|
||||
}
|
||||
@@ -316,8 +317,6 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -364,9 +363,6 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -487,13 +483,6 @@ interface SessionStoreActions {
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
setAgentLastActivityBatch: (
|
||||
@@ -566,7 +555,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -591,17 +579,26 @@ function isSessionServerInfoUnchanged(input: {
|
||||
currentServerInfo: SessionState["serverInfo"] | undefined;
|
||||
nextHostname: string | null;
|
||||
nextVersion: string | null;
|
||||
nextDesktopManaged: boolean | undefined;
|
||||
nextCapabilities: ServerCapabilities | undefined;
|
||||
nextFeatures: ServerInfoStatusPayload["features"] | undefined;
|
||||
nextServerId: string;
|
||||
}): boolean {
|
||||
const { currentServerInfo, nextHostname, nextVersion, nextCapabilities, nextFeatures } = input;
|
||||
const {
|
||||
currentServerInfo,
|
||||
nextHostname,
|
||||
nextVersion,
|
||||
nextDesktopManaged,
|
||||
nextCapabilities,
|
||||
nextFeatures,
|
||||
} = input;
|
||||
const prevHostname = currentServerInfo?.hostname?.trim() || null;
|
||||
const prevVersion = currentServerInfo?.version?.trim() || null;
|
||||
return (
|
||||
currentServerInfo?.serverId === input.nextServerId &&
|
||||
prevHostname === nextHostname &&
|
||||
prevVersion === nextVersion &&
|
||||
currentServerInfo?.desktopManaged === nextDesktopManaged &&
|
||||
areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) &&
|
||||
areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures)
|
||||
);
|
||||
@@ -721,6 +718,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
const nextHostname = info.hostname?.trim() || null;
|
||||
const nextVersion = info.version?.trim() || null;
|
||||
const nextDesktopManaged = info.desktopManaged;
|
||||
const nextCapabilities = info.capabilities;
|
||||
const nextFeatures = info.features;
|
||||
|
||||
@@ -729,6 +727,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
currentServerInfo: session.serverInfo,
|
||||
nextHostname,
|
||||
nextVersion,
|
||||
nextDesktopManaged,
|
||||
nextCapabilities,
|
||||
nextFeatures,
|
||||
nextServerId: info.serverId,
|
||||
@@ -747,6 +746,9 @@ export const useSessionStore = create<SessionStore>()(
|
||||
serverId: info.serverId,
|
||||
hostname: nextHostname,
|
||||
version: nextVersion,
|
||||
...(nextDesktopManaged !== undefined
|
||||
? { desktopManaged: nextDesktopManaged }
|
||||
: {}),
|
||||
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
||||
...(nextFeatures ? { features: nextFeatures } : {}),
|
||||
},
|
||||
@@ -1316,54 +1318,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
||||
return prev;
|
||||
}
|
||||
// A late dir-gone timeout must not override a successful restore:
|
||||
// only mark failed while still restoring and the descriptor is absent.
|
||||
if (
|
||||
status === "failed" &&
|
||||
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
||||
session.workspaces.has(workspaceId))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.set(workspaceId, status);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.delete(workspaceId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1377,18 +1331,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1408,7 +1354,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1627,14 +1572,3 @@ export const useSessionStore = create<SessionStore>()(
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export function useWorkspaceRestoreStatus(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceRestoreStatus | null {
|
||||
return useSessionStore((state) =>
|
||||
serverId && workspaceId
|
||||
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -971,6 +971,52 @@ export function collectAllPanes(root: SplitNode): SplitPane[] {
|
||||
return internalRoot.group.children.flatMap((child) => collectAllPanes(child));
|
||||
}
|
||||
|
||||
function isEphemeralTab(tab: WorkspaceTab): boolean {
|
||||
// Commit diff tabs are ephemeral: their SHA may be rebased away before the next
|
||||
// load, so a restored tab could point at a dead commit.
|
||||
return tab.target.kind === "commit_diff";
|
||||
}
|
||||
|
||||
function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal {
|
||||
if (node.kind === "pane") {
|
||||
const nextTabs = node.pane.tabs.filter((tab) => !isEphemeralTab(tab));
|
||||
if (nextTabs.length === node.pane.tabs.length) {
|
||||
return node;
|
||||
}
|
||||
// createPaneNode repoints focusedTabId to a surviving tab (or null) when the
|
||||
// previously focused tab was an ephemeral one that we just removed.
|
||||
return createPaneNode({
|
||||
id: node.pane.id,
|
||||
tabs: nextTabs,
|
||||
focusedTabId: node.pane.focusedTabId,
|
||||
});
|
||||
}
|
||||
return createGroupNode({
|
||||
id: node.group.id,
|
||||
direction: node.group.direction,
|
||||
children: node.group.children.map((child) => stripEphemeralTabsFromNode(child)),
|
||||
sizes: node.group.sizes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `layout` with every ephemeral tab (commit diff tabs) removed
|
||||
* from each pane. Applied in the layout store's `partialize` so commit diff tabs
|
||||
* are never written to storage — they're dropped on the next reload rather than
|
||||
* restored pointing at a possibly-rebased SHA. Working diff tabs and all other
|
||||
* tab kinds are left intact. Panes (and their ids/structure) are preserved even
|
||||
* when emptied; the parent-tab map is renormalized against the surviving tabs.
|
||||
*/
|
||||
export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): WorkspaceLayout {
|
||||
const internalLayout = asInternalLayout(layout);
|
||||
const nextRoot = stripEphemeralTabsFromNode(internalLayout.root);
|
||||
return withNormalizedParentTabMap({
|
||||
root: nextRoot,
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
parentTabIdByTabId: layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null {
|
||||
if (!layout) {
|
||||
return null;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
retargetTabInLayout,
|
||||
splitPaneEmptyInLayout,
|
||||
splitPaneInLayout,
|
||||
stripEphemeralTabsFromLayout,
|
||||
type SplitGroup,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -59,6 +60,7 @@ export {
|
||||
normalizeLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
stripEphemeralTabsFromLayout,
|
||||
};
|
||||
export type {
|
||||
SplitGroup,
|
||||
@@ -902,7 +904,11 @@ export function createWorkspaceLayoutStore(
|
||||
partialize: (state) => {
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in state.layoutByWorkspace) {
|
||||
layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]);
|
||||
// Strip ephemeral (commit diff) tabs before persisting so they are
|
||||
// dropped on reload rather than restored pointing at a rebased SHA.
|
||||
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
||||
normalizeLayout(state.layoutByWorkspace[key]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
applyRetargetTab,
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
initialWorkspaceTabsCoreState,
|
||||
migrateWorkspaceTabsState,
|
||||
type WorkspaceTabsCoreState,
|
||||
} from "./state";
|
||||
|
||||
@@ -334,6 +335,20 @@ describe("workspace-tabs-store reducers", () => {
|
||||
expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId);
|
||||
});
|
||||
|
||||
it("opens a commit diff tab with a commit-specific id", () => {
|
||||
let state = emptyState();
|
||||
const commit = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "commit_diff", sha: "abc123" },
|
||||
now: NOW,
|
||||
});
|
||||
state = commit.state;
|
||||
|
||||
expect(commit.tabId).toBe("commit_diff_abc123");
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => {
|
||||
let state = emptyState();
|
||||
const first = applyOpenOrFocusTab(state, {
|
||||
@@ -362,3 +377,56 @@ describe("workspace-tabs-store reducers", () => {
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateWorkspaceTabsState commit diff coercion", () => {
|
||||
// This legacy store no longer enforces the "commit diff tabs are ephemeral"
|
||||
// guarantee — that now lives in the workspace-layout store's partialize (see
|
||||
// stripEphemeralTabsFromLayout). This migration only needs to carry old commit
|
||||
// diff targets forward to the dedicated `commit_diff` tab shape.
|
||||
it("migrates a legacy commit diff tab to the dedicated target shape", () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
uiTabsByWorkspace: {
|
||||
[WORKSPACE_KEY]: [
|
||||
{
|
||||
tabId: "commit_diff_abc123",
|
||||
target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } },
|
||||
createdAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
|
||||
const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" });
|
||||
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]);
|
||||
});
|
||||
|
||||
it("drops a legacy working diff tab during migration", () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
uiTabsByWorkspace: {
|
||||
[WORKSPACE_KEY]: [
|
||||
{
|
||||
tabId: "diff_working:base:main",
|
||||
target: {
|
||||
kind: "diff",
|
||||
diffTarget: { kind: "working", mode: "base", baseRef: "main" },
|
||||
},
|
||||
createdAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
|
||||
|
||||
expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,8 @@ export type WorkspaceTabTarget =
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| WorkspaceFileTabTarget
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
| { kind: "setup"; workspaceId: string }
|
||||
| { kind: "commit_diff"; sha: string };
|
||||
|
||||
export interface WorkspaceTab {
|
||||
tabId: string;
|
||||
@@ -537,7 +538,35 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
|
||||
if (kind === "setup" && typeof raw.workspaceId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
||||
}
|
||||
return null;
|
||||
return coercePersistedDiffTabTargetByKind(kind, raw);
|
||||
}
|
||||
|
||||
function coercePersistedDiffTabTargetByKind(
|
||||
kind: string | null,
|
||||
raw: Record<string, unknown>,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (kind === "commit_diff" && typeof raw.sha === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha });
|
||||
}
|
||||
return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null;
|
||||
}
|
||||
|
||||
// NOTE: This legacy store no longer persists diff tabs — live tab persistence is
|
||||
// owned by the workspace-layout store, which is where the "commit diff tabs are
|
||||
// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout).
|
||||
// This coercion exists only so this store's migration stays type-complete for any
|
||||
// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff
|
||||
// tabs are dropped because they no longer exist as workspace targets; commit diff
|
||||
// tabs migrate to the dedicated `commit_diff` target shape.
|
||||
function coercePersistedDiffTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||
const diffTarget = toObjectRecord(raw.diffTarget);
|
||||
if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") {
|
||||
return null;
|
||||
}
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "commit_diff",
|
||||
sha: diffTarget.sha,
|
||||
});
|
||||
}
|
||||
|
||||
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { DiffLine } from "@/git/use-diff-query";
|
||||
|
||||
type ReviewSide = "old" | "new";
|
||||
type ReviewableLineType = "add" | "remove" | "context";
|
||||
|
||||
@@ -174,11 +174,16 @@ export function parseWorkspaceOpenIntent(
|
||||
export function parseHostWorkspaceOpenIntentFromPathname(
|
||||
pathname: string,
|
||||
): WorkspaceOpenIntent | null {
|
||||
return parseWorkspaceOpenIntent(getHostWorkspaceOpenParamFromPathname(pathname));
|
||||
}
|
||||
|
||||
export function getHostWorkspaceOpenParamFromPathname(pathname: string): string | null {
|
||||
const search = extractSearch(pathname);
|
||||
if (!search) {
|
||||
return null;
|
||||
}
|
||||
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
|
||||
const open = new URLSearchParams(search).get("open");
|
||||
return parseWorkspaceOpenIntent(open) ? open : null;
|
||||
}
|
||||
|
||||
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
||||
|
||||
@@ -1,62 +1,10 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
|
||||
export type { NavigateToAgentInput } from "./resolve";
|
||||
|
||||
// Clears the transient restoring state if the daemon resolves refreshAgent without
|
||||
// re-emitting a workspace_update (the directory-gone case), so the gate never spins
|
||||
// forever. Recreating a worktree can require a git fetch, so the budget is generous
|
||||
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
|
||||
const RESTORE_TIMEOUT_MS = 30000;
|
||||
|
||||
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
const client = snapshot?.client ?? null;
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
// Self-gate: only an archived agent whose workspace is absent needs restoring.
|
||||
// A still-present workspace or an in-flight restore is a no-op; fire-once is
|
||||
// derived from store state.
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
if (!agent?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
if (session?.workspaces.has(workspaceId)) {
|
||||
return;
|
||||
}
|
||||
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
return;
|
||||
}
|
||||
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
// Single capability read for restore. An old daemon recreates nothing on
|
||||
// refresh_agent, so a gone directory would spin then flash a misleading
|
||||
// "couldn't restore". Surface an explicit "update your host" state instead.
|
||||
if (session?.serverInfo?.features?.worktreeRestore !== true) {
|
||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
|
||||
return;
|
||||
}
|
||||
|
||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
|
||||
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
|
||||
setTimeout(
|
||||
() => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
|
||||
RESTORE_TIMEOUT_MS,
|
||||
);
|
||||
client
|
||||
.refreshAgent(agentId)
|
||||
.catch(() =>
|
||||
useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
|
||||
);
|
||||
}
|
||||
|
||||
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
return resolveNavigateToAgent(input, {
|
||||
readAgentNavTarget: ({ serverId, agentId }) => {
|
||||
@@ -70,8 +18,5 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
router.navigate(route as Href);
|
||||
},
|
||||
navigateToWorkspace,
|
||||
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
|
||||
restoreArchivedWorkspace(serverId, agentId, workspaceId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,25 +16,16 @@ interface RecordedHostNav {
|
||||
|
||||
interface RecordedTabNav extends NavigateToWorkspaceInput {}
|
||||
|
||||
interface RecordedRestore {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function createFakeNavigators(target: AgentNavTarget): {
|
||||
deps: NavigateToAgentDeps;
|
||||
hostNavigations: RecordedHostNav[];
|
||||
tabNavigations: RecordedTabNav[];
|
||||
restores: RecordedRestore[];
|
||||
} {
|
||||
const hostNavigations: RecordedHostNav[] = [];
|
||||
const tabNavigations: RecordedTabNav[] = [];
|
||||
const restores: RecordedRestore[] = [];
|
||||
return {
|
||||
hostNavigations,
|
||||
tabNavigations,
|
||||
restores,
|
||||
deps: {
|
||||
readAgentNavTarget: () => target,
|
||||
navigateToHostAgent: (route) => {
|
||||
@@ -44,9 +35,6 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
tabNavigations.push(input);
|
||||
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
|
||||
},
|
||||
restoreArchivedWorkspace: (input) => {
|
||||
restores.push(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,19 +62,6 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("delegates a restore attempt whenever a workspaceId resolves", () => {
|
||||
const { deps, restores, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
|
||||
|
||||
expect(restores).toEqual([
|
||||
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
|
||||
]);
|
||||
expect(tabNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
const readTargets: { serverId: string; agentId: string }[] = [];
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
|
||||
@@ -111,17 +86,6 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not trigger a restore when no workspaceId resolves", () => {
|
||||
const { deps, restores, hostNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
|
||||
|
||||
expect(restores).toEqual([]);
|
||||
expect(hostNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -19,11 +19,6 @@ export interface NavigateToAgentDeps {
|
||||
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
|
||||
navigateToHostAgent: (route: string) => void;
|
||||
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
|
||||
restoreArchivedWorkspace: (input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function resolveNavigateToAgent(
|
||||
@@ -41,14 +36,6 @@ export function resolveNavigateToAgent(
|
||||
return route;
|
||||
}
|
||||
|
||||
// Restore self-gates on the agent being archived with its workspace absent, so
|
||||
// ordinary navigations are a cheap no-op.
|
||||
deps.restoreArchivedWorkspace({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return deps.navigateToWorkspace({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
|
||||
const refreshAgent = vi.fn<(agentId: string) => Promise<unknown>>();
|
||||
let connected = true;
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
|
||||
}),
|
||||
isHostRuntimeConnected: () => connected,
|
||||
}));
|
||||
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "./index";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
function agent(archivedAt: Date | null): Agent {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: createdAt,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
archivedAt,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||
return (
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
store.updateSessionServerInfo(SERVER_ID, {
|
||||
serverId: SERVER_ID,
|
||||
hostname: "host",
|
||||
version: "0.1.98",
|
||||
features: { worktreeRestore: options?.worktreeRestore ?? true },
|
||||
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
refreshAgent.mockReset();
|
||||
connected = true;
|
||||
seedArchivedAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
});
|
||||
|
||||
function trigger(): void {
|
||||
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
}
|
||||
|
||||
it("calls refreshAgent once and marks the workspace restoring", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("does not re-fire while a restore for the same workspace is in flight", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
trigger();
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire for a non-archived agent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, agent(null));
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not fire while disconnected", () => {
|
||||
connected = false;
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("flips to failed when refreshAgent rejects", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
|
||||
|
||||
trigger();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.runAllTicks();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.resolve({}));
|
||||
|
||||
trigger();
|
||||
await Promise.resolve();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
seedArchivedAgent({ worktreeRestore: false });
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBe("needs-host-upgrade");
|
||||
});
|
||||
|
||||
it("is a no-op when the workspace descriptor is already present", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
]);
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
resolveTerminalProfiles,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target";
|
||||
import {
|
||||
isPinnedTargetAvailable,
|
||||
pinnedTargetKey,
|
||||
type PinnedTabTarget,
|
||||
} from "@/workspace-pins/target";
|
||||
import { usePinnedTargetsStore } from "@/workspace-pins/store";
|
||||
|
||||
export interface ResolvedPin {
|
||||
@@ -64,6 +69,9 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
|
||||
return useMemo(() => {
|
||||
const resolved: ResolvedPin[] = [];
|
||||
for (const target of pinned) {
|
||||
if (!isPinnedTargetAvailable(target, { isElectron: getIsElectron() })) {
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "draft") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTargetPinned, pinnedTargetKey, togglePinnedTarget } from "./target";
|
||||
import {
|
||||
isPinnedTargetAvailable,
|
||||
isTargetPinned,
|
||||
pinnedTargetKey,
|
||||
togglePinnedTarget,
|
||||
} from "./target";
|
||||
|
||||
describe("isPinnedTargetAvailable", () => {
|
||||
it("only offers browser targets in Electron", () => {
|
||||
const browser = { kind: "browser" } as const;
|
||||
|
||||
expect(isPinnedTargetAvailable(browser, { isElectron: true })).toBe(true);
|
||||
expect(isPinnedTargetAvailable(browser, { isElectron: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("offers cross-platform targets outside Electron", () => {
|
||||
const environment = { isElectron: false };
|
||||
|
||||
expect(isPinnedTargetAvailable({ kind: "draft" }, environment)).toBe(true);
|
||||
expect(isPinnedTargetAvailable({ kind: "terminal" }, environment)).toBe(true);
|
||||
expect(isPinnedTargetAvailable({ kind: "profile", profileId: "claude" }, environment)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinnedTargetKey", () => {
|
||||
it("uses the bare kind as the key for non-profile targets", () => {
|
||||
|
||||
@@ -4,6 +4,13 @@ export type PinnedTabTarget =
|
||||
| { kind: "browser" }
|
||||
| { kind: "profile"; profileId: string };
|
||||
|
||||
export function isPinnedTargetAvailable(
|
||||
target: PinnedTabTarget,
|
||||
environment: { isElectron: boolean },
|
||||
): boolean {
|
||||
return target.kind !== "browser" || environment.isElectron;
|
||||
}
|
||||
|
||||
export function pinnedTargetKey(target: PinnedTabTarget): string {
|
||||
if (target.kind === "profile") {
|
||||
return `profile:${target.profileId}`;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWorkspaceRecoveryModel } from "./model";
|
||||
|
||||
describe("resolveWorkspaceRecoveryModel", () => {
|
||||
it("keeps newer recovery actions visible but non-actionable", () => {
|
||||
expect(
|
||||
resolveWorkspaceRecoveryModel({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
hasClient: true,
|
||||
hasServerInfo: true,
|
||||
supportsRecovery: true,
|
||||
inspection: {
|
||||
pending: false,
|
||||
error: null,
|
||||
data: {
|
||||
kind: "recoverable",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Feature branch",
|
||||
action: "repair_from_snapshot",
|
||||
branch: "feature",
|
||||
},
|
||||
},
|
||||
restore: { pending: false, error: null },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "unsupportedAction",
|
||||
action: "repair_from_snapshot",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { WorkspaceRecoveryState as AuthoritativeWorkspaceRecoveryState } from "@getpaseo/protocol/messages";
|
||||
|
||||
type AuthoritativeRecoverableWorkspace = Extract<
|
||||
AuthoritativeWorkspaceRecoveryState,
|
||||
{ kind: "recoverable" }
|
||||
>;
|
||||
|
||||
export type SupportedWorkspaceRecoveryAction = "unarchive" | "restore";
|
||||
|
||||
type SupportedRecoverableWorkspace = Omit<AuthoritativeRecoverableWorkspace, "action"> & {
|
||||
action: SupportedWorkspaceRecoveryAction;
|
||||
};
|
||||
|
||||
export type WorkspaceRecoveryModel =
|
||||
| { kind: "idle" }
|
||||
| { kind: "checking" }
|
||||
| { kind: "needsHostUpgrade" }
|
||||
| {
|
||||
kind: "recoverable";
|
||||
recovery: SupportedRecoverableWorkspace;
|
||||
phase: "ready" | "restoring" | "failed";
|
||||
error: string | null;
|
||||
}
|
||||
| { kind: "unsupportedAction"; action: string }
|
||||
| {
|
||||
kind: "unavailable";
|
||||
recovery: Extract<AuthoritativeWorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
}
|
||||
| { kind: "inspectionFailed"; error: string };
|
||||
|
||||
export interface WorkspaceRecoveryController {
|
||||
state: WorkspaceRecoveryModel;
|
||||
restore: () => void;
|
||||
retryInspection: () => void;
|
||||
}
|
||||
|
||||
function resolveRecoveryPhase(input: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
}): "ready" | "restoring" | "failed" {
|
||||
if (input.pending) {
|
||||
return "restoring";
|
||||
}
|
||||
if (input.error) {
|
||||
return "failed";
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function getSupportedRecovery(
|
||||
recovery: AuthoritativeWorkspaceRecoveryState | undefined,
|
||||
): SupportedRecoverableWorkspace | null {
|
||||
if (recovery?.kind !== "recoverable") {
|
||||
return null;
|
||||
}
|
||||
if (recovery.action !== "restore" && recovery.action !== "unarchive") {
|
||||
return null;
|
||||
}
|
||||
return { ...recovery, action: recovery.action };
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRecoveryModel(input: {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
hasClient: boolean;
|
||||
hasServerInfo: boolean;
|
||||
supportsRecovery: boolean;
|
||||
inspection: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
data: AuthoritativeWorkspaceRecoveryState | undefined;
|
||||
};
|
||||
restore: { pending: boolean; error: string | null };
|
||||
}): WorkspaceRecoveryModel {
|
||||
const supportedRecovery = getSupportedRecovery(input.inspection.data);
|
||||
if (input.restore.pending && supportedRecovery) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
recovery: supportedRecovery,
|
||||
phase: "restoring",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (!input.enabled || !input.connected || !input.hasClient) {
|
||||
return { kind: "idle" };
|
||||
}
|
||||
if (!input.hasServerInfo) {
|
||||
return { kind: "checking" };
|
||||
}
|
||||
if (!input.supportsRecovery) {
|
||||
return { kind: "needsHostUpgrade" };
|
||||
}
|
||||
if (input.inspection.pending) {
|
||||
return { kind: "checking" };
|
||||
}
|
||||
if (input.inspection.error) {
|
||||
return { kind: "inspectionFailed", error: input.inspection.error };
|
||||
}
|
||||
if (input.inspection.data?.kind === "unavailable") {
|
||||
return { kind: "unavailable", recovery: input.inspection.data };
|
||||
}
|
||||
if (input.inspection.data?.kind === "recoverable" && !supportedRecovery) {
|
||||
return { kind: "unsupportedAction", action: input.inspection.data.action };
|
||||
}
|
||||
if (supportedRecovery) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
recovery: supportedRecovery,
|
||||
phase: resolveRecoveryPhase(input.restore),
|
||||
error: input.restore.error,
|
||||
};
|
||||
}
|
||||
return { kind: "checking" };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model";
|
||||
|
||||
export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model";
|
||||
|
||||
const MIN_RECOVERY_LOADING_MS = 350;
|
||||
|
||||
function waitForAnimationFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
async function waitForRecoveryLoadingPresentation(): Promise<void> {
|
||||
// The first callback runs before paint. Waiting for the following frame gives
|
||||
// React a paint opportunity before a fast daemon response adds the workspace.
|
||||
await waitForAnimationFrame();
|
||||
await waitForAnimationFrame();
|
||||
}
|
||||
|
||||
function waitForMinimumRecoveryLoadingTime(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, MIN_RECOVERY_LOADING_MS));
|
||||
}
|
||||
|
||||
export function useWorkspaceRecovery(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
enabled: boolean;
|
||||
}): WorkspaceRecoveryController {
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||
const serverInfo = useSessionStore((store) => store.sessions[input.serverId]?.serverInfo ?? null);
|
||||
const supportsRecovery = serverInfo?.features?.workspaceRecovery === true;
|
||||
|
||||
const inspection = useFetchQuery({
|
||||
queryKey: ["workspaceRecovery", input.serverId, input.workspaceId],
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5_000,
|
||||
enabled: Boolean(input.enabled && client && isConnected && supportsRecovery),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("The host client is unavailable.");
|
||||
}
|
||||
return client.inspectWorkspaceRecovery(input.workspaceId);
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("The host disconnected before the workspace could be recovered.");
|
||||
}
|
||||
await waitForRecoveryLoadingPresentation();
|
||||
await waitForMinimumRecoveryLoadingTime();
|
||||
await client.restoreWorkspace(input.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
const state = useMemo(
|
||||
() =>
|
||||
resolveWorkspaceRecoveryModel({
|
||||
enabled: input.enabled,
|
||||
connected: isConnected,
|
||||
hasClient: client !== null,
|
||||
hasServerInfo: serverInfo !== null,
|
||||
supportsRecovery,
|
||||
inspection: {
|
||||
pending: inspection.isPending,
|
||||
error: inspection.isError ? toErrorMessage(inspection.error) : null,
|
||||
data: inspection.data,
|
||||
},
|
||||
restore: {
|
||||
pending: restoreMutation.isPending,
|
||||
error: restoreMutation.isError ? toErrorMessage(restoreMutation.error) : null,
|
||||
},
|
||||
}),
|
||||
[
|
||||
client,
|
||||
input.enabled,
|
||||
inspection.data,
|
||||
inspection.error,
|
||||
inspection.isError,
|
||||
inspection.isPending,
|
||||
isConnected,
|
||||
restoreMutation.error,
|
||||
restoreMutation.isError,
|
||||
restoreMutation.isPending,
|
||||
serverInfo,
|
||||
supportsRecovery,
|
||||
],
|
||||
);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
restoreMutation.mutate();
|
||||
}, [restoreMutation]);
|
||||
const retryInspection = useCallback(() => {
|
||||
void inspection.refetch();
|
||||
}, [inspection]);
|
||||
|
||||
return { state, restore, retryInspection };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, it, test } from "vitest";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
@@ -43,3 +43,56 @@ describe("provider subagent tab identity", () => {
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit diff tab identity", () => {
|
||||
it("keys a commit diff tab by its sha", () => {
|
||||
expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe(
|
||||
"commit_diff_abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collide a commit diff tab id with a file tab id", () => {
|
||||
const diffId = buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" });
|
||||
const fileId = buildDeterministicWorkspaceTabId({
|
||||
kind: "file",
|
||||
path: "abc123",
|
||||
});
|
||||
expect(diffId).not.toBe(fileId);
|
||||
});
|
||||
|
||||
it("treats two commit diff targets with the same sha as equal", () => {
|
||||
expect(
|
||||
workspaceTabTargetsEqual(
|
||||
{ kind: "commit_diff", sha: "abc123" },
|
||||
{ kind: "commit_diff", sha: "abc123" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats commit diff targets with different shas as unequal", () => {
|
||||
expect(
|
||||
workspaceTabTargetsEqual(
|
||||
{ kind: "commit_diff", sha: "abc123" },
|
||||
{ kind: "commit_diff", sha: "def456" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes a commit diff target", () => {
|
||||
expect(
|
||||
normalizeWorkspaceTabTarget({
|
||||
kind: "commit_diff",
|
||||
sha: "abc123",
|
||||
}),
|
||||
).toEqual({ kind: "commit_diff", sha: "abc123" });
|
||||
});
|
||||
|
||||
it("rejects a commit diff target with a blank sha", () => {
|
||||
expect(
|
||||
normalizeWorkspaceTabTarget({
|
||||
kind: "commit_diff",
|
||||
sha: " ",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,22 +28,37 @@ export function normalizeWorkspaceTabTarget(
|
||||
? { kind: "provider_subagent", parentAgentId, subagentId }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
if (value.kind === "browser") {
|
||||
const browserId = trimNonEmpty(value.browserId);
|
||||
return browserId ? { kind: "browser", browserId } : null;
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
return normalizeFileTabTarget(value);
|
||||
}
|
||||
if (value.kind === "setup") {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId } : null;
|
||||
return normalizeSimpleWorkspaceTabTarget(value);
|
||||
}
|
||||
|
||||
function normalizeSimpleWorkspaceTabTarget(value: WorkspaceTabTarget): WorkspaceTabTarget | null {
|
||||
switch (value.kind) {
|
||||
case "agent": {
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
case "terminal": {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
case "browser": {
|
||||
const browserId = trimNonEmpty(value.browserId);
|
||||
return browserId ? { kind: "browser", browserId } : null;
|
||||
}
|
||||
case "setup": {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId } : null;
|
||||
}
|
||||
case "commit_diff": {
|
||||
const sha = trimNonEmpty(value.sha);
|
||||
return sha ? { kind: "commit_diff", sha } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDraftTabSetup(
|
||||
@@ -98,6 +113,9 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
if (left.kind === "commit_diff" && right.kind === "commit_diff") {
|
||||
return left.sha === right.sha;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -153,6 +171,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "setup") {
|
||||
return `setup_${target.workspaceId}`;
|
||||
}
|
||||
if (target.kind === "commit_diff") {
|
||||
return `commit_diff_${target.sha}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,23 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host";
|
||||
|
||||
export type DesktopOpenTargetKind = "editor" | "file-manager";
|
||||
export type DesktopOpenMode = "open" | "reveal";
|
||||
export type DesktopOpenTargetIcon =
|
||||
| { kind: "image"; dataUrl: string }
|
||||
| { kind: "symbol"; name: "folder" | "terminal" };
|
||||
|
||||
export interface DesktopOpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: DesktopOpenTargetKind;
|
||||
icon: DesktopOpenTargetIcon;
|
||||
}
|
||||
|
||||
export interface OpenDesktopTargetInput {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: DesktopOpenMode;
|
||||
workspacePath: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
interface AvailableDesktopEditorBridge {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isKnownEditorTargetId } from "./editor-targets";
|
||||
|
||||
describe("editor target ids", () => {
|
||||
it("recognizes built-ins without typing custom ids out of the system", () => {
|
||||
const customEditorId: string = "script:open-in-nvim";
|
||||
|
||||
expect(isKnownEditorTargetId("vscode")).toBe(true);
|
||||
expect(isKnownEditorTargetId(customEditorId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
export type EditorTargetId = string;
|
||||
|
||||
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
|
||||
"cursor",
|
||||
"vscode",
|
||||
"webstorm",
|
||||
"zed",
|
||||
"antigravity",
|
||||
"finder",
|
||||
"explorer",
|
||||
"file-manager",
|
||||
]);
|
||||
|
||||
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
|
||||
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
|
||||
}
|
||||
@@ -2,8 +2,18 @@ import { describe, expect, it } from "vitest";
|
||||
import { planWorkspaceOpenTargets } from "./open-target-planner";
|
||||
|
||||
const desktopTargets = [
|
||||
{ id: "vscode", label: "VS Code", kind: "editor" as const },
|
||||
{ id: "finder", label: "Finder", kind: "file-manager" as const },
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
kind: "editor" as const,
|
||||
icon: { kind: "symbol" as const, name: "terminal" as const },
|
||||
},
|
||||
{
|
||||
id: "finder",
|
||||
label: "Finder",
|
||||
kind: "file-manager" as const,
|
||||
icon: { kind: "symbol" as const, name: "folder" as const },
|
||||
},
|
||||
];
|
||||
|
||||
const checkoutStatus = {
|
||||
@@ -25,7 +35,12 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
expect(targets[0]).toMatchObject({
|
||||
source: "desktop",
|
||||
id: "vscode",
|
||||
openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" },
|
||||
openInput: {
|
||||
editorId: "vscode",
|
||||
workspacePath: "/repo",
|
||||
filePath: "/repo/src/app.ts",
|
||||
line: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +56,11 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
expect(targets[1]).toMatchObject({
|
||||
source: "desktop",
|
||||
id: "finder",
|
||||
openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" },
|
||||
openInput: {
|
||||
editorId: "finder",
|
||||
workspacePath: "/repo",
|
||||
filePath: "/repo/src/app.ts",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,12 +75,12 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
expect(targets[0]).toMatchObject({
|
||||
source: "desktop",
|
||||
id: "vscode",
|
||||
openInput: { editorId: "vscode", path: "/repo" },
|
||||
openInput: { editorId: "vscode", workspacePath: "/repo" },
|
||||
});
|
||||
expect(targets[1]).toMatchObject({
|
||||
source: "desktop",
|
||||
id: "finder",
|
||||
openInput: { editorId: "finder", path: "/repo" },
|
||||
openInput: { editorId: "finder", workspacePath: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +88,14 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
const targets = planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile: { path: "src/app.ts" },
|
||||
desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }],
|
||||
desktopTargets: [
|
||||
{
|
||||
id: "script:open-in-nvim",
|
||||
label: "Open in Neovim",
|
||||
kind: "editor",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
},
|
||||
],
|
||||
canUseDesktopBridge: true,
|
||||
isLocalExecution: true,
|
||||
});
|
||||
@@ -80,10 +106,11 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
id: "script:open-in-nvim",
|
||||
label: "Open in Neovim",
|
||||
editorId: "script:open-in-nvim",
|
||||
icon: { kind: "symbol", name: "terminal" },
|
||||
openInput: {
|
||||
editorId: "script:open-in-nvim",
|
||||
path: "/repo/src/app.ts",
|
||||
cwd: "/repo",
|
||||
workspacePath: "/repo",
|
||||
filePath: "/repo/src/app.ts",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user