Compare commits

...

28 Commits

Author SHA1 Message Date
Mohamed Boudra
c37684b246 Preserve entitlements when re-signing managed runtime binaries
The sign script was re-signing Mach-O executables with --force and
hardened runtime but without --entitlements, stripping entitlements
like allow-jit that Node.js needs for V8. This caused SIGTRAP on
any Mac where the binary went through Gatekeeper validation.

Extract existing entitlements before re-signing and pass them back
via --entitlements so they are preserved.
2026-03-11 11:36:21 +07:00
Mohamed Boudra
e2068e3d72 chore(release): cut 0.1.25 2026-03-11 11:09:56 +07:00
Mohamed Boudra
443eb16e67 Notarize macOS DMG to fix quarantine on bundled binaries
Tauri notarizes the .app but not the .dmg container. When users
download the DMG from GitHub Releases, macOS quarantines everything
and Gatekeeper doesn't clear quarantine on embedded helper binaries
(like the bundled Node runtime), causing SIGTRAP on first launch.

Add a post-build step that signs, notarizes, and staples the DMG,
then re-uploads it to the release.
2026-03-11 11:09:41 +07:00
Mohamed Boudra
240dc26013 Restore AppImage bundle for Linux (revert deb workaround)
The linuxdeploy failure was caused by CUDA shared library references in
onnxruntime-node, not by linuxdeploy itself. The CUDA stripping step
added in the previous commit fixes the root cause, so AppImage bundling
should work now.
2026-03-11 09:07:44 +07:00
Mohamed Boudra
6b07555a46 Switch Linux bundle from appimage to deb
linuxdeploy-plugin-appimage's "continuous" release on GitHub is broken,
causing every AppImage build to fail. The downloaded binary is actually
an HTML error page. Switch to deb format which doesn't depend on
linuxdeploy at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:21:09 +07:00
Mohamed Boudra
b69bd5271b Fix Linux AppImage build: add APPIMAGE_EXTRACT_AND_RUN=1
linuxdeploy is an AppImage itself and needs FUSE to run. GitHub Actions
runners don't always have working FUSE support. Setting this env var
tells AppImage tools to extract-and-run instead, avoiding the FUSE
dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:07:48 +07:00
Mohamed Boudra
cf4cae2c7d Fix Linux AppImage: strip CUDA deps from onnxruntime binaries
linuxdeploy scans all ELF binaries in the AppDir and fails when it
can't find libcublasLt.so.12 (a CUDA library referenced by the
onnxruntime native module). Use patchelf to remove these optional
CUDA dependencies since we only need CPU inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:59:36 +07:00
Mohamed Boudra
d51f18a2f7 Add workflow step to strip CUDA providers before Linux AppImage build
The build script fix only applies to future tags. For v0.1.24 (and any
tag built before that fix), we need the workflow itself to remove the
CUDA .so files after building the managed runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:54 +07:00
Mohamed Boudra
006db65f08 Remove CUDA/TensorRT providers from onnxruntime-node in managed runtime
linuxdeploy scans all ELF files in the AppDir and fails when it finds
libonnxruntime_providers_cuda.so which links to libcublasLt.so.12 — a
CUDA library not available on CI runners.

onnxruntime falls back to the CPU provider when CUDA is absent, so
removing these has no functional impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:32 +07:00
Mohamed Boudra
f21221c1e1 Add libfuse2 to Linux AppImage build dependencies
linuxdeploy is distributed as an AppImage and may need libfuse2 to
execute even with APPIMAGE_EXTRACT_AND_RUN=1. ubuntu-22.04 runners
don't have it by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:26:11 +07:00
Mohamed Boudra
9faa88e13b Add --verbose to Linux AppImage build for debugging
Need to see the actual linuxdeploy error output instead of the opaque
"failed to run linuxdeploy" message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:16:34 +07:00
Mohamed Boudra
faf1eed0ab Fix Linux AppImage build: pin ubuntu-22.04 and disable strip
ubuntu-latest switched to 24.04 which has libraries with .relr.dyn
sections that linuxdeploy's bundled eu-strip cannot handle, causing
consistent "failed to run linuxdeploy" errors.

Pin to ubuntu-22.04 (also better glibc compat for AppImage) and set
NO_STRIP=true as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:06:38 +07:00
Mohamed Boudra
8fc37eac52 Update CHANGELOG for v0.1.24
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:24:06 +07:00
Mohamed Boudra
9e76d1c2d6 Use --no-bundle for desktop smoke builds
Smoke tags have non-numeric pre-release identifiers (e.g. gha-smoke.1)
which MSI bundler rejects. Since smoke builds only need to prove Rust
compilation succeeds, skip bundling entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:01:49 +07:00
Mohamed Boudra
5609c89517 Simplify desktop release pipeline: single build, no process smoke test
Replace the 855-line managed-daemon-smoke.mjs (which spawned relay
servers, daemons, and tested E2E connectivity in CI) with a fast
validate-managed-runtime.mjs that checks the bundle is correctly
assembled without launching any processes.

Structural changes:
- Eliminate double-build: removed the pre-build step that compiled the
  Tauri app just for smoke testing before tauri-action rebuilt it
- Move version-setting before the build so there's no version confusion
- Sign managed runtime before tauri-action build (macOS)
- Smoke tags now do a real tauri build instead of --no-bundle, giving
  actual signal about whether the release would succeed
- Reduce each platform job from ~15 steps to ~12

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:43:06 +07:00
Mohamed Boudra
4e4a751921 Improve command center keyboard navigation and new tab shortcut 2026-03-10 20:32:22 +07:00
Mohamed Boudra
6b4978b428 Clean up Windows smoke relay shutdown 2026-03-10 20:22:07 +07:00
Mohamed Boudra
e1f4e6fafb Fix Windows smoke relay launch 2026-03-10 19:43:30 +07:00
Mohamed Boudra
f09b43eef0 Tighten Windows desktop smoke loop 2026-03-10 19:22:54 +07:00
Mohamed Boudra
2813f35eb1 Relax Windows smoke app build failures 2026-03-10 18:49:10 +07:00
Mohamed Boudra
90dfe36e3e Speed up Windows desktop smoke 2026-03-10 18:27:02 +07:00
Mohamed Boudra
7588c1791b Remove accidental release notes 2026-03-10 18:23:43 +07:00
Mohamed Boudra
bfa7f65c3d chore(release): cut 0.1.24 2026-03-10 18:12:16 +07:00
Mohamed Boudra
51bbebcdd5 Fix Windows smoke npm invocation 2026-03-10 18:12:09 +07:00
Mohamed Boudra
7b4ca8394b chore(release): cut 0.1.23 2026-03-10 18:01:55 +07:00
Mohamed Boudra
438a9f6d48 Fix Windows smoke path resolution 2026-03-10 18:01:39 +07:00
Mohamed Boudra
9604b8d57b chore(release): cut 0.1.22 2026-03-10 17:44:28 +07:00
Mohamed Boudra
2a0b0b9109 Fix Windows runtime packaging 2026-03-10 17:44:16 +07:00
24 changed files with 653 additions and 286 deletions

View File

@@ -54,29 +54,61 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Normalize release tag
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" == desktop-windows-v* ]]; then
release_tag="v${source_tag#desktop-windows-v}"
elif [[ "$source_tag" == desktop-linux-v* ]]; then
release_tag="v${source_tag#desktop-linux-v}"
elif [[ "$source_tag" == desktop-macos-v* ]]; then
release_tag="v${source_tag#desktop-macos-v}"
elif [[ "$source_tag" == desktop-v* ]]; then
release_tag="v${source_tag#desktop-v}"
else
release_tag="$source_tag"
fi
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -107,79 +139,23 @@ jobs:
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime for macOS
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Import Apple code-signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Build packaged app for macOS smoke
shell: bash
run: |
set -euo pipefail
app_path="packages/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/Paseo.app/Contents/MacOS/Paseo"
if npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }}; then
exit 0
fi
if [[ -f "$app_path" ]]; then
echo "Smoke app created at $app_path despite tauri build exiting non-zero; continuing."
exit 0
fi
exit 1
- name: Smoke check managed runtime for macOS
env:
PASEO_MANAGED_SMOKE_SKIP_BUILD: "1"
PASEO_MANAGED_SMOKE_RUST_TARGET: ${{ matrix.rust_target }}
run: npm run smoke:managed-daemon --workspace=@getpaseo/desktop
- name: Sign bundled managed runtime for macOS
- name: Sign bundled managed runtime
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.SOURCE_TAG;
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
@@ -196,6 +172,7 @@ jobs:
- name: Build and publish macOS Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: tauri_build
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -216,12 +193,54 @@ jobs:
prerelease: false
args: --target ${{ matrix.rust_target }}
- name: Notarize and re-upload DMG
if: env.IS_SMOKE_TAG != 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
if [ -z "$dmg_path" ]; then
echo "::error::No DMG found in tauri build artifacts"
exit 1
fi
echo "DMG: $dmg_path"
echo "Signing DMG..."
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
echo "Submitting DMG for notarization..."
xcrun notarytool submit "$dmg_path" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
echo "Stapling notarization ticket..."
xcrun stapler staple "$dmg_path"
echo "Verifying..."
spctl --assess --type install --verbose "$dmg_path"
echo "Replacing release asset with notarized DMG..."
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
- name: Build macOS app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
contents: write
packages: read
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -229,33 +248,65 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Normalize release tag
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" == desktop-windows-v* ]]; then
release_tag="v${source_tag#desktop-windows-v}"
elif [[ "$source_tag" == desktop-linux-v* ]]; then
release_tag="v${source_tag#desktop-linux-v}"
elif [[ "$source_tag" == desktop-macos-v* ]]; then
release_tag="v${source_tag#desktop-macos-v}"
elif [[ "$source_tag" == desktop-v* ]]; then
release_tag="v${source_tag#desktop-v}"
else
release_tag="$source_tag"
fi
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
- name: Setup Node
uses: actions/setup-node@v4
@@ -285,50 +336,24 @@ jobs:
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime for Linux
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Smoke check managed runtime for Linux
run: npm run smoke:managed-daemon --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Set desktop version from tag
- name: Strip CUDA dependencies from onnxruntime
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.SOURCE_TAG;
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
echo "Removing needed $lib from $f"
patchelf --remove-needed "$lib" "$f"
done
done
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
@@ -351,6 +376,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NO_STRIP: "true"
APPIMAGE_EXTRACT_AND_RUN: "1"
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
@@ -360,6 +387,10 @@ jobs:
prerelease: false
args: --bundles appimage
- name: Build Linux app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
publish-windows:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
permissions:
@@ -373,29 +404,61 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Normalize release tag
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" == desktop-windows-v* ]]; then
release_tag="v${source_tag#desktop-windows-v}"
elif [[ "$source_tag" == desktop-linux-v* ]]; then
release_tag="v${source_tag#desktop-linux-v}"
elif [[ "$source_tag" == desktop-macos-v* ]]; then
release_tag="v${source_tag#desktop-macos-v}"
elif [[ "$source_tag" == desktop-v* ]]; then
release_tag="v${source_tag#desktop-v}"
else
release_tag="$source_tag"
fi
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -424,50 +487,11 @@ jobs:
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime for Windows
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Smoke check managed runtime for Windows
run: npm run smoke:managed-daemon --workspace=@getpaseo/desktop
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.SOURCE_TAG;
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
@@ -498,3 +522,10 @@ jobs:
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --bundles nsis,msi
- name: Build Windows app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}

View File

@@ -1,5 +1,11 @@
# Changelog
## 0.1.24 - 2026-03-10
### Improved
- Improved command center keyboard navigation and new tab shortcut.
- Simplified desktop release pipeline for faster and more reliable builds.
## 0.1.21 - 2026-03-10
### Improved
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.21",
"version": "0.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.21",
"version": "0.1.25",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -27561,7 +27561,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.21",
"version": "0.1.25",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -27569,7 +27569,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.21",
"@getpaseo/server": "0.1.25",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -27707,11 +27707,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.21",
"version": "0.1.25",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.21",
"@getpaseo/server": "0.1.21",
"@getpaseo/relay": "0.1.25",
"@getpaseo/server": "0.1.25",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -27748,14 +27748,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.21",
"version": "0.1.25",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.21",
"version": "0.1.25",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -27771,12 +27771,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.21",
"version": "0.1.25",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.21",
"@getpaseo/relay": "0.1.25",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -28132,7 +28132,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.21",
"version": "0.1.25",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.21",
"version": "0.1.25",
"private": true,
"workspaces": [
"packages/server",

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.21",
"version": "0.1.25",
"private": true,
"scripts": {
"start": "expo start",
@@ -33,7 +33,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.21",
"@getpaseo/server": "0.1.25",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -7,6 +7,7 @@ import {
View,
Platform,
} from "react-native";
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
@@ -20,6 +21,37 @@ function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
type CommandCenterRowProps = {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
};
const CommandCenterRow = memo(function CommandCenterRow({
active,
children,
onPress,
registerRow,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
return (
<Pressable
ref={registerRow}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={onPress}
>
{children}
</Pressable>
);
});
export function CommandCenter() {
const { theme } = useUnistyles();
const {
@@ -33,10 +65,52 @@ export function CommandCenter() {
handleSelectItem,
} = useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
useEffect(() => {
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") {
return;
}
const scrollNode =
(resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
}, [activeIndex]);
if (Platform.OS !== "web") return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
const actionItems = useMemo(
() => items.filter((item) => item.kind === "action"),
[items]
);
const agentItems = useMemo(
() => items.filter((item) => item.kind === "agent"),
[items]
);
return (
<Modal
@@ -71,6 +145,7 @@ export function CommandCenter() {
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
@@ -105,14 +180,13 @@ export function CommandCenter() {
/>
) : null;
return (
<Pressable
<CommandCenterRow
key={`action:${action.id}`}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(index, el);
else rowRefs.current.delete(index);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
@@ -133,7 +207,7 @@ export function CommandCenter() {
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</Pressable>
</CommandCenterRow>
);
})}
</>
@@ -154,14 +228,13 @@ export function CommandCenter() {
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<Pressable
<CommandCenterRow
key={agentKey(agent)}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
@@ -184,12 +257,12 @@ export function CommandCenter() {
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
</Pressable>
</CommandCenterRow>
);
})}
</>
@@ -277,6 +350,8 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
textContent: {
flex: 1,
minWidth: 0,
gap: 2,
},
rowShortcut: {

View File

@@ -3,7 +3,9 @@ import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
@@ -23,8 +25,7 @@ function isMatch(agent: AggregatedAgent, query: string): boolean {
const q = query.toLowerCase();
const title = (agent.title ?? "New agent").toLowerCase();
const cwd = agent.cwd.toLowerCase();
const host = agent.serverLabel.toLowerCase();
return title.includes(q) || cwd.includes(q) || host.includes(q);
return title.includes(q) || cwd.includes(q);
}
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
@@ -103,34 +104,49 @@ export type CommandCenterItem =
export function useCommandCenter() {
const pathname = usePathname();
const { agents } = useAggregatedAgents();
const { daemons } = useDaemonRegistry();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
const activeIndexRef = useRef(0);
const itemsRef = useRef<CommandCenterItem[]>([]);
const handleCloseRef = useRef<() => void>(() => undefined);
const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = useMemo(() => {
const serverIdFromPath = parseServerIdFromPathname(pathname);
if (serverIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
if (routeMatch) {
return routeMatch.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, pathname]);
const { agents } = useAllAgentsList({
serverId: activeServerId,
});
const agentResults = useMemo(() => {
const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents);
return filtered;
}, [agents, query]);
const fallbackServerId = agents[0]?.serverId ?? null;
const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath =
parseServerIdFromPathname(pathname) ?? fallbackServerId;
const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
}, [fallbackServerId, pathname]);
}, [activeServerId]);
const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath =
parseServerIdFromPathname(pathname) ?? fallbackServerId;
const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
}, [fallbackServerId, pathname]);
}, [activeServerId]);
const actionItems = useMemo(() => {
return COMMAND_CENTER_ACTIONS.filter((action) =>
@@ -209,6 +225,22 @@ export function useCommandCenter() {
[handleSelectAction, handleSelectAgent]
);
useEffect(() => {
activeIndexRef.current = activeIndex;
}, [activeIndex]);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
handleCloseRef.current = handleClose;
}, [handleClose]);
useEffect(() => {
handleSelectItemRef.current = handleSelectItem;
}, [handleSelectItem]);
useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
@@ -259,6 +291,7 @@ export function useCommandCenter() {
if (!open) return;
const handler = (event: KeyboardEvent) => {
const currentItems = itemsRef.current;
const key = event.key;
if (
key !== "ArrowDown" &&
@@ -271,26 +304,29 @@ export function useCommandCenter() {
if (key === "Escape") {
event.preventDefault();
handleClose();
handleCloseRef.current();
return;
}
if (key === "Enter") {
if (items.length === 0) return;
if (currentItems.length === 0) return;
event.preventDefault();
const index = Math.max(0, Math.min(activeIndex, items.length - 1));
handleSelectItem(items[index]!);
const index = Math.max(
0,
Math.min(activeIndexRef.current, currentItems.length - 1)
);
handleSelectItemRef.current(currentItems[index]!);
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (items.length === 0) return;
if (currentItems.length === 0) return;
event.preventDefault();
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
if (next < 0) return items.length - 1;
if (next >= items.length) return 0;
if (next < 0) return currentItems.length - 1;
if (next >= currentItems.length) return 0;
return next;
});
}
@@ -299,7 +335,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelectItem, items, open]);
}, [open]);
return {
open,

View File

@@ -152,8 +152,9 @@ describe("keyboard-shortcuts", () => {
payload: { delta: 1 },
},
{
name: "matches Alt+Shift+T to open new tab",
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
name: "matches Mod+T to open new tab",
event: { key: "t", code: "KeyT", metaKey: true },
context: { isMac: true },
action: "workspace.tab.new",
},
{
@@ -222,6 +223,10 @@ describe("keyboard-shortcuts", () => {
event: { key: "n", code: "KeyN", metaKey: true, altKey: true },
context: { isMac: true },
},
{
name: "does not keep old Alt+Shift+T binding",
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
},
{
name: "does not match question-mark shortcut inside editable scopes",
event: { key: "?", code: "Slash", shiftKey: true },
@@ -269,6 +274,7 @@ describe("keyboard-shortcut help sections", () => {
context: { isMac: true, isTauri: false },
expectedKeys: {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
@@ -279,6 +285,7 @@ describe("keyboard-shortcut help sections", () => {
context: { isMac: true, isTauri: true },
expectedKeys: {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["mod", "W"],

View File

@@ -138,20 +138,19 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
},
},
{
id: "workspace-tab-new-alt-shift-t",
id: "workspace-tab-new-mod-t",
action: "workspace.tab.new",
matches: (event) =>
!event.metaKey &&
!event.ctrlKey &&
event.altKey &&
event.shiftKey &&
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyT" || event.key.toLowerCase() === "t"),
when: (context) => !context.commandCenterOpen,
help: {
id: "workspace-tab-new",
section: "global",
label: "New agent tab",
keys: ["alt", "shift", "T"],
keys: ["mod", "T"],
},
},
{

View File

@@ -10,6 +10,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import {
@@ -367,7 +368,10 @@ export function WorkspaceDesktopTabsRow({
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</View>
@@ -488,4 +492,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
newTabTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
newTabTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
}));

View File

@@ -26,6 +26,7 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { Combobox } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import {
DropdownMenu,
DropdownMenuContent,
@@ -1157,38 +1158,36 @@ function WorkspaceScreenContent({
return;
}
if (workspaceTabActionRequest.kind === "new") {
const request = workspaceTabActionRequest;
clearWorkspaceTabActionRequest(request.id);
if (request.kind === "new") {
handleCreateDraftTab();
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
return;
}
if (workspaceTabActionRequest.kind === "close-current") {
if (request.kind === "close-current") {
if (activeTabId) {
void handleCloseTabById(activeTabId);
}
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
return;
}
if (workspaceTabActionRequest.kind === "navigate-index") {
const next = tabs[workspaceTabActionRequest.index - 1] ?? null;
if (request.kind === "navigate-index") {
const next = tabs[request.index - 1] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
return;
}
if (workspaceTabActionRequest.kind === "navigate-relative") {
if (request.kind === "navigate-relative") {
if (tabs.length > 0) {
const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId);
const fromIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex =
(fromIndex + workspaceTabActionRequest.delta + tabs.length) % tabs.length;
const nextIndex = (fromIndex + request.delta + tabs.length) % tabs.length;
const next = tabs[nextIndex] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
}
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
}
}, [
activeTabId,
@@ -1525,7 +1524,10 @@ function WorkspaceScreenContent({
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -1728,6 +1730,15 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
newTabTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
newTabTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
mobileTabsRow: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.21",
"version": "0.1.25",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.21",
"@getpaseo/server": "0.1.21",
"@getpaseo/relay": "0.1.25",
"@getpaseo/server": "0.1.25",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.21",
"version": "0.1.25",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {
@@ -8,6 +8,7 @@
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime",
"dev": "npm run prepare:managed-runtime && tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
"tauri": "tauri"
},

View File

@@ -196,10 +196,13 @@ function runCommand(command, args, options = {}) {
encoding: "utf8",
...options,
});
if (result.status !== 0) {
if (result.status !== 0 || result.error) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.error ? String(result.error) : null,
result.signal ? `signal: ${result.signal}` : null,
result.status != null ? `exit code: ${result.status}` : null,
result.stdout?.trim(),
result.stderr?.trim(),
]
@@ -245,7 +248,17 @@ async function ensureWorkspaceBuilds() {
async function packWorkspace(packageRoot, tarballRoot) {
await ensureDir(tarballRoot);
const result = runCommand("npm", ["pack", "--json", "--pack-destination", tarballRoot], {
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("Missing npm_execpath while building managed runtime.");
}
const result = runCommand(process.execPath, [
npmExecPath,
"pack",
"--json",
"--pack-destination",
tarballRoot,
], {
cwd: packageRoot,
});
const [{ filename }] = JSON.parse(result.stdout.trim());
@@ -347,6 +360,15 @@ async function pruneOnnxRuntime(runtimeRoot) {
await removeIfExists(path.join(onnxRoot, "darwin"));
await removeIfExists(path.join(onnxRoot, "win32"));
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
const archDir = path.join(onnxRoot, "linux", process.arch);
if (await pathExists(archDir)) {
const entries = await fs.readdir(archDir);
await Promise.all(
entries
.filter((name) => name.includes("cuda") || name.includes("tensorrt"))
.map((name) => fs.rm(path.join(archDir, name), { force: true }))
);
}
return;
}
if (process.platform === "win32") {

View File

@@ -5,12 +5,14 @@ import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import WebSocket from "ws";
import { createClientChannel } from "@getpaseo/relay/e2ee";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
const COMMAND_TIMEOUT_MS = 120_000;
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const relayRoot = path.join(repoRoot, "packages", "relay");
const desktopPackageJson = JSON.parse(
@@ -108,13 +110,42 @@ function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function waitForChildExit(child, timeoutMs) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, timeoutMs);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
async function execFileWithTimeout(command, args, options, label) {
try {
return await execFileAsync(command, args, {
timeout: COMMAND_TIMEOUT_MS,
...options,
});
} catch (error) {
if (error?.killed && error?.signal === "SIGTERM") {
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
throw new Error(
`Timed out after ${COMMAND_TIMEOUT_MS}ms running ${label}: ${JSON.stringify(command)} ${renderedArgs}`
);
}
throw error;
}
}
async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
const { stdout, stderr } = await execFileWithTimeout(binaryPath, args, {
env,
cwd: repoRoot,
maxBuffer: 10 * 1024 * 1024,
});
}, "packaged desktop binary");
const trimmed = stdout.trim();
return {
stdout,
@@ -123,8 +154,36 @@ async function runBinary(binaryPath, args, env) {
};
}
async function terminateChildProcess(child, label) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
if (process.platform === "win32" && typeof child.pid === "number") {
try {
await execFileWithTimeout(
"taskkill",
["/pid", String(child.pid), "/T", "/F"],
{ maxBuffer: 1024 * 1024 },
`${label} taskkill`
);
} catch {}
await waitForChildExit(child, 5_000);
return;
}
try {
child.kill("SIGTERM");
} catch {}
await waitForChildExit(child, 5_000);
if (child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {}
await waitForChildExit(child, 5_000);
}
}
async function runWorkspaceCli(args, env) {
const { stdout, stderr } = await execFileAsync(
const { stdout, stderr } = await execFileWithTimeout(
process.execPath,
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
{
@@ -135,6 +194,7 @@ async function runWorkspaceCli(args, env) {
},
maxBuffer: 10 * 1024 * 1024,
},
"workspace CLI"
);
let json = null;
if (stdout.trim()) {
@@ -151,7 +211,7 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
const { stdout, stderr } = await execFileAsync(
const { stdout, stderr } = await execFileWithTimeout(
path.join(runtimeRoot, manifest.nodeRelativePath),
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
{
@@ -162,7 +222,8 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
PASEO_HOME: managedHome,
},
maxBuffer: 10 * 1024 * 1024,
}
},
"bundled runtime CLI"
);
return { stdout, stderr };
}
@@ -361,12 +422,16 @@ async function ensurePackagedArtifact(binaryPath) {
if (process.env.PASEO_MANAGED_SMOKE_SKIP_BUILD === "1") {
return;
}
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to build the packaged desktop artifact during smoke tests.");
}
try {
await execFileAsync("npm", ["run", "build"], {
await execFileWithTimeout(process.execPath, [npmExecPath, "run", "build"], {
cwd: desktopRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
});
}, "desktop smoke artifact build");
} catch (error) {
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
const signingBlocked =
@@ -463,10 +528,17 @@ let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to launch wrangler during managed desktop smoke tests.");
}
try {
logStep(`Starting isolated local relay on ${relayEndpoint}`);
relayProcess = spawn(process.platform === "win32" ? "npx.cmd" : "npx", [
relayProcess = spawn(process.execPath, [
npmExecPath,
"exec",
"--",
"wrangler",
"dev",
"--local",
@@ -615,11 +687,11 @@ try {
logStep("Skipping privileged CLI shim install in CI and verifying the bundled CLI target directly");
}
const cliVersion = cliShimInstalled
? await execFileAsync(cliShimPath, ["--version"], {
? await execFileWithTimeout(cliShimPath, ["--version"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
})
}, "installed CLI shim version check")
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.managedHome,
@@ -628,11 +700,11 @@ try {
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const shimStatus = cliShimInstalled
? await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
? await execFileWithTimeout(cliShimPath, ["daemon", "status", "--json"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
})
}, "installed CLI shim daemon status")
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.managedHome,
@@ -645,14 +717,15 @@ try {
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = cliShimInstalled
? await execFileAsync(
? await execFileWithTimeout(
cliShimPath,
["daemon", "pair", "--home", managedStart.managedHome],
{
env: managedEnv,
cwd: repoRoot,
maxBuffer: 4 * 1024 * 1024,
}
},
"installed CLI shim relay pairing"
)
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
@@ -776,6 +849,6 @@ try {
}
} catch {}
try {
relayProcess?.kill("SIGTERM");
await terminateChildProcess(relayProcess, "local relay");
} catch {}
}

View File

@@ -74,6 +74,17 @@ function needsHardenedRuntime(fileKind) {
return fileKind.includes("executable");
}
function extractEntitlements(file) {
const result = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", file], {
stdio: "pipe",
encoding: "utf8",
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return result.stdout;
}
async function main() {
const pointer = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
@@ -99,6 +110,15 @@ async function main() {
signTargets.sort((left, right) => left.file.localeCompare(right.file));
for (const target of signTargets) {
let entitlementsFile = null;
if (target.needsRuntime) {
const entitlements = extractEntitlements(target.file);
if (entitlements) {
entitlementsFile = `${target.file}.entitlements.plist`;
await fs.writeFile(entitlementsFile, entitlements, "utf8");
}
}
const args = [
"--force",
"--sign",
@@ -107,10 +127,17 @@ async function main() {
];
if (target.needsRuntime) {
args.push("--options", "runtime");
if (entitlementsFile) {
args.push("--entitlements", entitlementsFile);
}
}
args.push(target.file);
console.log(`[managed-runtime-sign] ${path.relative(repoRoot, target.file)}`);
run("codesign", args);
if (entitlementsFile) {
await fs.unlink(entitlementsFile);
}
}
}

View File

@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const desktopRoot = fileURLToPath(new URL("..", import.meta.url));
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const expectedVersion = desktopPackageJson.version;
const currentRuntime = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
);
assert.equal(currentRuntime.runtimeVersion, expectedVersion, "current-runtime.json version mismatch");
assert.ok(currentRuntime.runtimeId, "current-runtime.json missing runtimeId");
assert.ok(currentRuntime.relativeRoot, "current-runtime.json missing relativeRoot");
const runtimeRoot = path.join(resourcesRoot, currentRuntime.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
assert.equal(manifest.runtimeId, currentRuntime.runtimeId, "manifest runtimeId mismatch");
assert.equal(manifest.runtimeVersion, expectedVersion, "manifest version mismatch");
assert.ok(manifest.nodeRelativePath, "manifest missing nodeRelativePath");
assert.ok(manifest.cliEntrypointRelativePath, "manifest missing cliEntrypointRelativePath");
assert.ok(manifest.serverRunnerRelativePath, "manifest missing serverRunnerRelativePath");
const nodeBinary = path.join(runtimeRoot, manifest.nodeRelativePath);
await fs.access(nodeBinary).catch(() => {
throw new Error(`Bundled Node binary not found: ${nodeBinary}`);
});
const cliEntry = path.join(runtimeRoot, manifest.cliEntrypointRelativePath);
await fs.access(cliEntry).catch(() => {
throw new Error(`CLI entrypoint not found: ${cliEntry}`);
});
const serverRunner = path.join(runtimeRoot, manifest.serverRunnerRelativePath);
await fs.access(serverRunner).catch(() => {
throw new Error(`Server runner not found: ${serverRunner}`);
});
const runtimePackageJson = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "package.json"), "utf8")
);
assert.equal(runtimePackageJson.version, expectedVersion, "runtime package.json version mismatch");
for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
const pkgDir = path.join(runtimeRoot, "node_modules", ...pkg.split("/"));
await fs.access(pkgDir).catch(() => {
throw new Error(`Missing bundled dependency: ${pkg} (expected at ${pkgDir})`);
});
}
console.log(`[validate-managed-runtime] PASS`);
console.log(` runtimeId: ${manifest.runtimeId}`);
console.log(` version: ${expectedVersion}`);
console.log(` platform: ${manifest.platform}`);
console.log(` arch: ${manifest.arch}`);

View File

@@ -2629,7 +2629,7 @@ dependencies = [
[[package]]
name = "paseo"
version = "0.1.21"
version = "0.1.24"
dependencies = [
"base64 0.22.1",
"dirs",

View File

@@ -1,6 +1,6 @@
[package]
name = "paseo"
version = "0.1.21"
version = "0.1.25"
description = "Paseo Desktop"
authors = ["moboudra"]
license = "MIT"
@@ -44,3 +44,7 @@ tokio-tungstenite = "0.24"

View File

@@ -7,9 +7,9 @@
<key>CFBundleName</key>
<string>Paseo</string>
<key>CFBundleShortVersionString</key>
<string>0.1.21</string>
<string>0.1.25</string>
<key>CFBundleVersion</key>
<string>0.1.21</string>
<string>0.1.25</string>
<key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict>

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Paseo",
"version": "0.1.21",
"version": "0.1.25",
"identifier": "dev.paseo.desktop",
"build": {
"frontendDist": "../../app/dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.21",
"version": "0.1.25",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.21",
"version": "0.1.25",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -63,7 +63,7 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.21",
"@getpaseo/relay": "0.1.25",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.21",
"version": "0.1.25",
"private": true,
"type": "module",
"scripts": {