Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 5e8eec44c1 fix(android): keep signing secrets out of build logs
Run Expo prebuild and serialized Gradle directly instead of passing signing credentials through the local EAS payload. Verify the finished APK against the production signing fingerprint before upload.
2026-07-16 17:53:20 +02:00
Mohamed Boudra 0b820d729a ci(android): stop consuming EAS credits for APKs
Build production-signed APKs on GitHub while leaving AAB store builds on EAS. Manual dispatches default to artifact-only dry runs so arbitrary refs can be verified without publishing.
2026-07-16 17:42:56 +02:00
adradrandMohamed Boudra 0d3b717cf3 Git commit history (#1534)
* feat(app): add fileView preference for changes panel

* feat(app): add buildDiffTree util for changed-files tree

* feat(app): add changed-files tree directory row

* feat(app): changed-files tree view mode in changes panel (#117)

* feat(protocol): add checkout.commits.list RPC + commitsList feature flag

* feat(server): list branch commits ahead of base with on-remote flags

* feat(server): handle checkout.commits.list RPC and advertise capability

* feat(client): checkout.commits.list method + useCommitsQuery hook

* feat(app): per-commit inline view with local-vs-remote markers (#117)

* feat(protocol,server): per-commit file diff RPC (checkout.commits.file_diff)

* feat(app): open per-commit file diff on click (#117)

* refactor: surface baseRef + commits loading/error, drop dead depth field

* fix(app): flip commit local/remote dot — local hollow, remote filled

* feat(app): list/tree view for expanded commit file list (#117)

* fix(app): syntax-highlight per-commit file diff to match Changes view

* feat(app): draggable resize between commits and diff sections

* refactor(app): dedupe wrap-text helpers into diff-highlighted-text

* fix(app): clean up commits resize drag on unmount + a11y label

* fix(app): collapse commits section by default

* fix(app): render per-commit file diff with the shared Changes line renderer

* perf(app): memoize shared diff line row; drop redundant DiffLineView wrapper

* refactor(app): extract shared DiffFileBody; render commit file diff through it

* fix(app): hide inline-comment affordance in per-commit diffs (no reviewActions)

* feat(app): move commits to a resizable bottom drawer in the Changes panel

* feat(review): commitSha scoping for per-commit review drafts + attachment

* feat(app): per-commit inline comments wired through to the composer (#117)

* refactor(app): own commit file-diff open state in CommitFileList (drop reset effect)

* refactor(app): colocate diff-render cluster under git/diff-file-body/

* feat(app): add diff tab target kind (working/commit diff tabs)

* feat(app): useDiffFiles hook unifying working + commit diff targets

* feat(app): diff tab panel rendering working/commit diffs

* feat(app): open a commit diff tab on commit click; drop per-commit drawer/file list

* feat(app): open/scroll working diff tab on changed-file click

* fix(app): keep commit diff tabs ephemeral; align working diff whitespace

* feat(app): collapsible file sections in the diff tab, collapsed by default

* feat(app): make the on-remote commit dot more subtle

Dims the filled green remote dot (row + legend) to ~0.55 opacity so the
local-only ring stays the state that draws the eye.

* Reshape commit diffs around the existing Changes view

* Fix diff tab migration complexity after rebase

* Address diff tab review findings

* Clean up diff tab review interfaces

* Collapse commit diffs into the existing view

* Load commits when expanded

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-16 17:36:41 +02:00
Mohamed Boudra 5da6548aff Improve the archived workspace restore flow (#2002)
* fix(app): restore missing workspaces from History

Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.

* fix(app): wait for workspace hydration before restore

* fix(app): preserve History agent unarchive

* fix(app): reopen archived History agents outside active directory

History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.

* fix(app): ignore stale History archive state for active agents

A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.

* fix(app): reopen every selected archived History agent

History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.

* fix(app): require explicit workspace recovery

* fix: preserve workspace recovery contracts

* fix: preserve workspace recovery compatibility

* fix: preserve recovered workspace session state

* fix: preserve terminal navigation timing

* fix(server): preserve successful workspace recovery

* fix: preserve workspace recovery intent
2026-07-16 17:22:37 +02:00
Mohamed Boudra d42ab91971 Show agent history errors without a one-minute wait (#2124)
* fix(client): fail invalid RPC responses immediately

Correlated responses that failed schema validation were discarded before request matching, leaving callers blocked until the RPC timeout. Preserve only the raw correlation identity at the boundary so the matching request fails with a protocol error.

* test(client): preserve invalid response diagnostics

* fix(client): ignore invalid correlated progress events
2026-07-16 16:44:01 +02:00
Mohamed Boudra e528a0db06 Remove custom providers from settings (#1951)
* feat(providers): remove custom providers from settings

Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.

* test(app): cover provider removal with e2e

Move provider removal coverage out of mocked component tests and into the real Settings flow.

* fix(providers): keep removal live after config updates
2026-07-16 16:11:51 +02:00
Mohamed Boudra 6aba0370ae Make remote daemon update failures actionable (#2120)
* fix(settings): show daemon update failures

React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path.

* test(settings): harden daemon update regression

* fix(settings): disable Desktop-managed daemon updates

Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm.
2026-07-16 21:04:31 +08:00
Mohamed Boudra f4509fe044 Open files in more installed editors (#2119)
* feat(desktop): open files in more installed editors

Detect bundled app commands as well as PATH launchers and preserve file positions through editor-specific launches.

* fix(desktop): preserve editor target compatibility

Keep existing file-manager preference ids, retain project context for file launches, and recognize Windows 64-bit IDE commands.
2026-07-16 14:39:55 +02:00
Jason@HNDandMohamed Boudra 3e8dce7d7c Hide browser shortcuts outside the desktop app (#2116)
* fix: hide browser pin on non-Electron web

Browser is desktop-only, but the pinned shortcut bar still rendered a
New browser pin on web where createBrowser is a silent no-op.

Skip browser pins in usePinnedLaunchers when not Electron, and drop
browser from default pinned targets so new users only get terminal.

* fix(app): preserve browser pin defaults on desktop

Keep platform availability in the pinned-target policy so regular web hides browser shortcuts without changing Electron defaults.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-16 12:36:20 +00:00
Mohamed Boudra 47532952f3 Catch broken desktop packages before release (#2114)
* test(desktop): exercise packaged app startup

The previous smoke exited before IPC, window creation, and preload execution, so a broken packaged preload could still pass. Launch the normal app and observe its renderer bridge and managed daemon through CDP instead.

* test(desktop): preserve Linux sandbox in smoke

Configure the unpacked artifact's chrome-sandbox helper with the permissions Electron requires, while keeping sandboxing enabled so preload failures remain observable. Bound startup probes and include process logs in daemon timeout failures.

* ci(desktop): keep packaged smoke on releases

Remove the temporary pull-request artifact build after it validated the Linux launch path. Keep the real packaged smoke in the native desktop release matrix.

* ci(desktop): gate packaged smoke by changes

Run the Linux real-artifact smoke in pull-request CI when packages/desktop changes, while leaving the regular desktop unit-test matrix unconditional.
2026-07-16 12:04:45 +02:00
paseo-ai[bot] d7ca1b5a03 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-16 07:55:15 +00:00
187 changed files with 8693 additions and 2783 deletions
+217 -74
View File
@@ -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 }}"
+34
View File
@@ -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:
+31 -1
View File
@@ -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:
-6
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+33
View File
@@ -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
View File
@@ -1 +1 @@
sha256-TiKnZMEypZYb6GGj189XUeb77j6voamTs6IPJye0UyE=
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
+21 -21
View File
@@ -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
View File
@@ -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": [
+7
View File
@@ -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: {
+4 -6
View File
@@ -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);
});
});
+13
View File
@@ -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;
-6
View File
@@ -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();
+93
View File
@@ -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);
});
});
}
+5 -3
View File
@@ -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,
},
}),
);
+5 -1
View File
@@ -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 };
+1 -6
View File
@@ -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}"]`);
+96
View File
@@ -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);
}
});
});
+45 -1
View File
@@ -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,
});
+216 -44
View File
@@ -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 -1
View File
@@ -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>
);
+7 -30
View File
@@ -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 } : {}),
});
+7
View File
@@ -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,
+5 -3
View File
@@ -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],
},
}));
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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,
+12
View File
@@ -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" });
});
});
+102
View File
@@ -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,
});
}
+101
View File
@@ -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,
}),
]);
});
});
+117
View File
@@ -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]);
}
+3 -2
View File
@@ -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"];
+2 -1
View File
@@ -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");
+45 -3
View File
@@ -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: "تحميل",
+45 -4
View File
@@ -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",
+46 -4
View File
@@ -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",
+45 -4
View File
@@ -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",
+46 -4
View File
@@ -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: "読み込み中",
+46 -4
View File
@@ -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",
+46 -4
View File
@@ -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: "Загрузка",
+44 -3
View File
@@ -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
);
}
+108 -59
View File
@@ -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", () => {
+16 -82
View File
@@ -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 {
+2 -1
View File
@@ -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";
+6 -1
View File
@@ -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();
});
});
+9 -1
View File
@@ -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),
+25 -1
View File
@@ -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();
});
});
+33 -12
View File
@@ -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