Compare commits

...

21 Commits

Author SHA1 Message Date
Mohamed Boudra
26d69e2006 chore(release): cut 0.1.41 2026-04-01 19:23:40 +07:00
Mohamed Boudra
4b7c623592 docs(changelog): add 0.1.41 release notes 2026-04-01 19:21:48 +07:00
Mohamed Boudra
b91884bc54 fix(desktop): enable default context menu for copy/paste across the app 2026-04-01 19:19:40 +07:00
Mohamed Boudra
963c79265a fix(desktop): eliminate white flash on window resize in dark mode
Set native window backgroundColor to match the app's surface0 color so
the backing layer is dark during resize repaints. Extend the existing
updateWindowControls IPC to also call setBackgroundColor on all platforms
(including macOS), keeping the renderer as the single source of truth
for theme resolution. Add a prefers-color-scheme CSS rule in index.html
to cover the HTML-to-React mount gap.
2026-04-01 18:33:17 +07:00
Mohamed Boudra
ade1e338ea fix: show modifier keys and missing keys during shortcut rebinding
Add Tab, F1-F12, Delete, Home, End, PageUp, PageDown, Insert to the
key map so they can be captured during rebinding. Show held modifier
keys (Ctrl, Alt, Shift, Cmd) as live feedback matching VS Code's
keydown-only approach — modifiers persist after release until the
next keypress. Cancel capture when navigating away from settings.
2026-04-01 18:32:31 +07:00
Mohamed Boudra
c13972c835 fix: replace Unix path assumptions with cross-platform isAbsolutePath
Windows daemon paths like C:\Users\... don't start with "/", breaking
the explorer sidebar, checkout status, terminals, and file attachments
when connected to a Windows daemon. Consolidate three private
isAbsolutePath implementations into a shared utility and use it
everywhere, with correct file URI conversion for Windows and UNC paths.
2026-04-01 18:20:02 +07:00
Mohamed Boudra
d8d04c545e docs(release): clarify retry tag rebuilds 2026-04-01 17:40:23 +07:00
Mohamed Boudra
613450bac8 fix: remove 40-item cap on activity curator timeline output
`paseo logs` was only showing the last 40 collapsed timeline items
due to DEFAULT_MAX_ITEMS. Setting to 0 disables the cap so the full
timeline is shown by default. --tail still works for limiting output.
2026-04-01 17:38:32 +07:00
Mohamed Boudra
cafff08a30 fix: rewrite titlebar drag to match VS Code's static pattern
Replace the stateful TitlebarDragRegion (hooks, ResizeObserver, IPC,
fullscreen tracking) with a pure static component — matching VS Code's
titlebar-drag-region exactly: position absolute, full size, no z-index,
no pointer-events, no state, no event listeners.

Remove TitlebarNoDragContent entirely — VS Code doesn't wrap content in
no-drag; interactive elements get no-drag from the global CSS backstop
in index.html.

Add drag regions to all header surfaces:
- ScreenHeader (sessions, workspace header)
- Left sidebar (traffic light area + header)
- Split container pane tabs
- Explorer sidebar header (Changes/Files tabs)

Fix workspace header empty space not draggable by changing
headerTitleContainer from flex: 1 to flexShrink: 1.
2026-04-01 17:36:26 +07:00
Mohamed Boudra
cb60f2a596 Fix Windows default shell for terminal creation 2026-04-01 17:25:41 +07:00
Mohamed Boudra
58d72bea87 refactor: migrate to VS Code titlebar drag region pattern 2026-04-01 16:00:33 +07:00
Mohamed Boudra
953f7898e7 docs(release): clarify workflow dispatch retries 2026-04-01 15:43:24 +07:00
Mohamed Boudra
fd06e109be fix(release): use bash for release env steps 2026-04-01 15:40:34 +07:00
Mohamed Boudra
ba1bb1646e docs(release): clarify stable vs rc flow 2026-04-01 15:37:07 +07:00
github-actions[bot]
3ee11efcd0 fix: update lockfile signatures and Nix hash 2026-04-01 08:36:00 +00:00
Mohamed Boudra
1930a8a2f2 chore(release): cut 0.1.41-rc.1 2026-04-01 15:33:41 +07:00
Mohamed Boudra
f7fd41a5f8 chore(release): add rc prerelease flow 2026-04-01 15:33:27 +07:00
Mohamed Boudra
3af5b0f031 Merge remote-tracking branch 'origin/main' into codex/rc-release-0.1.41 2026-04-01 15:21:26 +07:00
Mohamed Boudra
cce8dee21c fix(server): use shell on Windows for all provider spawns
Windows npm shims (e.g. C:\nvm4w\nodejs\codex) fail with ENOENT when
spawned without shell. Use `shell: true` on win32 for all provider
launches instead of the overly complex shouldUseWindowsShell function.
2026-04-01 15:19:28 +07:00
Mohamed Boudra
2d02db6ae0 fix(app): improve light mode theming
- Make PaseoLogo theme-aware (uses foreground color instead of hardcoded white)
- Add shadow tokens (sm/md/lg) to theme with per-scheme opacity, radius, and offset
- Replace all 16 hardcoded shadow instances with spreadable theme.shadow tokens
- Fix button icon color for default variant (use accentForeground, not foreground)
- Fix dark background flash on startup (root layout used hardcoded darkTheme)
- Add theme.colorScheme to replace fragile hex-string dark mode detection
- Add scrollbarHandle and surfaceWorkspace tokens to eliminate isDark branching
2026-04-01 14:24:17 +07:00
github-actions[bot]
66732a2f48 fix: update lockfile signatures and Nix hash 2026-04-01 06:43:36 +00:00
90 changed files with 1632 additions and 1050 deletions

View File

@@ -34,15 +34,33 @@ jobs:
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[2]}"
else
release_tag="$source_tag"
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -107,24 +125,6 @@ jobs:
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Wait for GitHub release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 90); do
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Found release for tag $RELEASE_TAG"
exit 0
fi
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
sleep 20
done
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
exit 1
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,7 +4,9 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
workflow_dispatch:
jobs:

View File

@@ -15,6 +15,7 @@ on:
jobs:
deploy:
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
runs-on: ubuntu-latest
steps:

View File

@@ -58,24 +58,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
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
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -110,24 +93,6 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -167,21 +132,7 @@ jobs:
steps:
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
@@ -277,24 +228,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
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
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -329,24 +263,6 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -378,24 +294,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
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
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -438,24 +337,6 @@ jobs:
npx expo export --platform web
working-directory: packages/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:

View File

@@ -19,11 +19,6 @@ on:
required: false
default: false
type: boolean
draft:
description: "Create missing release as draft."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
@@ -48,7 +43,6 @@ jobs:
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
INPUT_DRAFT: ${{ github.event.inputs.draft }}
shell: bash
run: |
set -euo pipefail
@@ -70,8 +64,4 @@ jobs:
args+=(--create-if-missing)
fi
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
args+=(--draft)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"

View File

@@ -1,5 +1,22 @@
# Changelog
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
- Fixed context menu for copy/paste across the desktop app.
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1F12).
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added

View File

@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
## Cloud build + submit (EAS)
Tag pushes like `v0.1.0` trigger:
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands
```bash

View File

@@ -2,6 +2,13 @@
All workspaces share one version and release together.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
```bash
@@ -12,6 +19,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step
```bash
@@ -21,27 +30,44 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Draft release flow
## Release candidate flow
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
```
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
Use the RC path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
```bash
# Desktop (all platforms)
@@ -54,21 +80,29 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
@@ -76,11 +110,23 @@ The website depends on the changelog to determine the latest download version. T
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
npmDepsHash = "sha256-WUTgYClIpHYsIrKjmlxvYxM6dS9jkYtAS0WLAH2nR/I=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.40",
"version": "0.1.41",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34962,16 +34962,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.40",
"version": "0.1.41",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.41",
"@getpaseo/highlight": "0.1.41",
"@getpaseo/server": "0.1.41",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35088,11 +35088,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.40",
"version": "0.1.41",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/relay": "0.1.41",
"@getpaseo/server": "0.1.41",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35133,11 +35133,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.40",
"version": "0.1.41",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/cli": "0.1.41",
"@getpaseo/server": "0.1.41",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35171,7 +35171,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.40",
"version": "0.1.41",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35372,7 +35372,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.40",
"version": "0.1.41",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35398,7 +35398,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.40",
"version": "0.1.41",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35414,13 +35414,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.40",
"version": "0.1.41",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/relay": "0.1.40",
"@getpaseo/highlight": "0.1.41",
"@getpaseo/relay": "0.1.41",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35818,7 +35818,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.40",
"version": "0.1.41",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.41",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -41,18 +41,23 @@
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "!v*-rc.*"
workflow_dispatch: {}
jobs:

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.40",
"version": "0.1.41",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.41",
"@getpaseo/highlight": "0.1.41",
"@getpaseo/server": "0.1.41",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -19,6 +19,12 @@
body {
overflow: hidden;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
}
}
/* These styles make the root element full-height */
#root {
display: flex;

View File

@@ -17,7 +17,6 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
@@ -69,7 +68,7 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -477,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
@@ -495,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme]);
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
return (
<VoiceProvider>
@@ -603,6 +606,7 @@ function FaviconStatusSync() {
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
return (
<Stack
@@ -610,7 +614,7 @@ function RootStack() {
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
backgroundColor: theme.colors.surface0,
},
}}
>
@@ -654,8 +658,10 @@ function NavigationActiveWorkspaceObserver() {
}
export default function RootLayout() {
const { theme } = useUnistyles();
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>

View File

@@ -1,4 +1,5 @@
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
}
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
if (isAbsolutePath(attachment.storageKey)) {
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
// UNC paths: \\server\share -> file://server/share
if (attachment.storageKey.startsWith("\\\\")) {
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
}
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
}
return attachment.storageKey;
},

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
});
it("converts Windows drive-letter paths to file URIs", () => {
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
});
it("converts UNC paths to host-based file URIs", () => {
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
});
it("passes through file URIs unchanged", () => {
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
});
it("passes through relative paths unchanged", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});

View File

@@ -1,4 +1,5 @@
import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (!isAbsolutePath(path)) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
return path;
// UNC paths: \\server\share -> file://server/share
if (path.startsWith("\\\\")) {
return `file:${path.replace(/\\/g, "/")}`;
}
return `file:///${path.replace(/\\/g, "/")}`;
}
export function fileUriToPath(uri: string): string {

View File

@@ -1444,11 +1444,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
maxHeight: 400,
overflow: "hidden",
},

View File

@@ -1127,14 +1127,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
...theme.shadow.sm,
},
scrollToBottomIcon: {
color: theme.colors.foreground,

View File

@@ -268,10 +268,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],

View File

@@ -0,0 +1,57 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
/**
* VS Code-style titlebar drag region for Electron.
*
* Copied from VS Code at commit daa0a70:
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
*
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
* no state, no event listeners. Interactive elements get no-drag from their own
* CSS (global backstop in index.html). The drag region never re-renders.
*
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
* On macOS, Electron handles edge resize natively.
*/
/**
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
return null;
}
return (
<>
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
<div
style={{
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
}}
/>
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
<div
style={{
position: "absolute",
top: 0,
width: "100%",
height: 4,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
}}
/>
</>
);
}

View File

@@ -108,11 +108,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
textContainer: {
flex: 1,

View File

@@ -18,6 +18,7 @@ import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -344,6 +345,7 @@ function SidebarContent({
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
@@ -434,6 +436,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
header: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",

View File

@@ -119,7 +119,7 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";

View File

@@ -86,9 +86,8 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colorScheme === "dark";
// Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;

View File

@@ -8,7 +8,8 @@ import {
HEADER_TOP_PADDING_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -31,8 +32,6 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const dragHandlers = useDesktopDragHandlers();
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
@@ -45,8 +44,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
},
borderless && styles.borderless,
]}
{...dragHandlers}
>
<TitlebarDragRegion />
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
</View>
@@ -61,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
},
inner: {},
row: {
position: "relative",
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,

View File

@@ -1,16 +1,20 @@
import Svg, { Path } from "react-native-svg";
import { useUnistyles } from "react-native-unistyles";
interface PaseoLogoProps {
size?: number;
color?: string;
}
export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) {
export function PaseoLogo({ size = 64, color }: PaseoLogoProps) {
const { theme } = useUnistyles();
const fill = color ?? theme.colors.foreground;
return (
<Svg width={size} height={size} viewBox="0 0 700 700" fill="none">
<Path
d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z"
fill={color}
fill={fill}
/>
</Svg>
);

View File

@@ -35,7 +35,8 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
@@ -638,7 +639,6 @@ function DesktopSidebar({
handleViewMore,
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -689,10 +689,13 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
</View>
@@ -831,6 +834,9 @@ const styles = StyleSheet.create((theme) => ({
width: 10,
zIndex: 10,
},
sidebarDragArea: {
position: "relative",
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,

View File

@@ -269,10 +269,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],

View File

@@ -2022,11 +2022,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...theme.shadow.md,
},
projectRowLeft: {
flexDirection: "row",
@@ -2169,11 +2165,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surface1,

View File

@@ -33,6 +33,7 @@ import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -887,6 +888,7 @@ function SplitPaneView({
{ paddingLeft: padding.left, paddingRight: padding.right },
]}
>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
@@ -997,6 +999,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
paneTabs: {
position: "relative",
minWidth: 0,
},
paneContent: {

View File

@@ -259,11 +259,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
toastSuccess: {
borderColor: theme.colors.border,

View File

@@ -248,11 +248,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
detailLabel: {
color: theme.colors.foreground,
@@ -275,11 +271,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
scrollView: {
flexGrow: 0,

View File

@@ -136,9 +136,11 @@ export function Button({
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const color = variant === "default"
? theme.colors.accentForeground
: variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function

View File

@@ -845,11 +845,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
maxHeight: 400,
overflow: "hidden",
},

View File

@@ -722,11 +722,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
overflow: "hidden",
},
sheetBackground: {

View File

@@ -605,11 +605,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
labelContainer: {
paddingHorizontal: theme.spacing[3],

View File

@@ -535,11 +535,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
...theme.shadow.sm,
zIndex: 1000,
},
}));

View File

@@ -25,11 +25,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
buttonIdle: {
backgroundColor: theme.colors.surface2,

View File

@@ -350,8 +350,7 @@ export function WebDesktopScrollbarOverlay({
? HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const isDark = theme.colors.surface0 === "#181B1A";
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
const handleColor = theme.colors.scrollbarHandle;
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;

View File

@@ -320,7 +320,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID="welcome-screen"
>
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}

View File

@@ -1,4 +1,8 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
import {
getDesktopHost,
type DesktopWindowBridge,
type DesktopWindowControlsOverlayUpdate,
} from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
export async function updateDesktopWindowControls(
update: DesktopWindowControlsOverlayUpdate,
): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
if (!win || typeof win.updateWindowControls !== "function") {
return;
}
await win.setTitleBarTheme(theme);
await win.updateWindowControls(update);
}

View File

@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
}) => Promise<void>;
}
export interface DesktopWindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
moving?: (screenX: number, screenY: number) => void;
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);

View File

@@ -128,11 +128,7 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3],
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 8,
...theme.shadow.md,
maxWidth: 480,
},
closeButton: {

View File

@@ -1,4 +1,5 @@
import { getDesktopHost } from "@/desktop/host";
import { isAbsolutePath } from "@/utils/path";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
@@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [
"svg",
];
function isAbsoluteWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value);
}
function shouldTreatAsFileUri(uri: string): boolean {
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
return uri.startsWith("file://") || isAbsolutePath(uri);
}
async function blobFromUri(uri: string): Promise<Blob> {

View File

@@ -43,6 +43,17 @@ KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
KEY_MAP["ArrowUp"] = { code: "ArrowUp" };
KEY_MAP["ArrowDown"] = { code: "ArrowDown" };
KEY_MAP["Tab"] = { code: "Tab" };
KEY_MAP["Delete"] = { code: "Delete" };
KEY_MAP["Home"] = { code: "Home" };
KEY_MAP["End"] = { code: "End" };
KEY_MAP["PageUp"] = { code: "PageUp" };
KEY_MAP["PageDown"] = { code: "PageDown" };
KEY_MAP["Insert"] = { code: "Insert" };
for (let i = 1; i <= 12; i++) {
KEY_MAP[`F${i}`] = { code: `F${i}` };
}
const CODE_TO_KEY: Record<string, string> = {};
for (const [humanKey, mapping] of Object.entries(KEY_MAP)) {
@@ -170,6 +181,15 @@ export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
return s.split(" ").map(comboStringToShortcutKeys);
}
export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
const parts: string[] = [];
if (event.ctrlKey) parts.push("Ctrl");
if (event.altKey) parts.push("Alt");
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
return parts.length > 0 ? parts.join("+") : null;
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null;

View File

@@ -51,7 +51,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
@@ -243,7 +243,6 @@ function DraftAgentScreenContent({
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout,
);
const dragHandlers = useDesktopDragHandlers();
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const draftIdRef = useRef(generateDraftId());
const draftAgentIdRef = useRef(generateDraftId());
@@ -999,7 +998,8 @@ function DraftAgentScreenContent({
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
const mainContent = (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<TitlebarDragRegion />
<View style={styles.outerContainer}>
<View style={styles.agentPanel}>
<View
@@ -1292,6 +1292,7 @@ function DraftAgentScreenContent({
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
flex: 1,
backgroundColor: theme.colors.surface0,
},

View File

@@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openAgentList = usePanelStore((s) => s.openAgentList);
@@ -18,7 +18,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isCompactLayout = isCompactFormFactor();
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
if (!isCompactLayout) {
@@ -29,7 +28,8 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content} {...dragHandlers}>
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
@@ -58,6 +58,7 @@ const styles = StyleSheet.create((theme) => ({
userSelect: "none",
},
content: {
position: "relative",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { View, Text, Platform } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
@@ -13,18 +14,30 @@ import {
import {
chordStringToShortcutKeys,
comboStringToShortcutKeys,
heldModifiersFromEvent,
keyboardEventToComboString,
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
function ShortcutSequence({
chord,
heldModifiers,
}: {
chord: string[] | null;
heldModifiers: string | null;
}) {
if ((!chord || chord.length === 0) && !heldModifiers) {
return <Text style={styles.capturingText}>Press shortcut...</Text>;
}
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
const displayCombos = [...(chord ?? [])];
if (heldModifiers) {
displayCombos.push(heldModifiers);
}
return <Shortcut chord={displayCombos.map(comboStringToShortcutKeys)} />;
}
function ShortcutRow({
@@ -33,6 +46,7 @@ function ShortcutRow({
overrideCombo,
isCapturing,
capturedCombos,
heldModifiers,
onRebind,
onDone,
onCancel,
@@ -43,6 +57,7 @@ function ShortcutRow({
overrideCombo: string | undefined;
isCapturing: boolean;
capturedCombos: string[];
heldModifiers: string | null;
onRebind: () => void;
onDone: () => void;
onCancel: () => void;
@@ -55,7 +70,7 @@ function ShortcutRow({
<Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}>
{isCapturing ? (
<ShortcutSequence chord={capturedCombos} />
<ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />
) : (
<Shortcut chord={displayChord} />
)}
@@ -88,22 +103,32 @@ function ShortcutRow({
export function KeyboardShortcutsSection() {
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
const [heldModifiers, setHeldModifiers] = useState<string | null>(null);
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
useKeyboardShortcutOverrides();
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isFocused = useIsFocused();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
useEffect(() => {
if (!isFocused && capturingBindingId !== null) {
cancelCapture();
}
}, [isFocused]);
function cancelCapture() {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
function startCapture(bindingId: string) {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
@@ -132,9 +157,11 @@ export function KeyboardShortcutsSection() {
const comboString = keyboardEventToComboString(event);
if (comboString === null) {
setHeldModifiers(heldModifiersFromEvent(event));
return;
}
setHeldModifiers(null);
setCapturedCombos((current) => [...current, comboString]);
}
@@ -194,6 +221,7 @@ export function KeyboardShortcutsSection() {
overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId}
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
heldModifiers={capturingBindingId === bindingId ? heldModifiers : null}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);

View File

@@ -11,7 +11,7 @@ import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
type StartupSplashScreenProps = {
bootstrapState?: {
@@ -26,6 +26,7 @@ const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
flex: 1,
alignItems: "center",
justifyContent: "center",
@@ -140,7 +141,6 @@ const styles = StyleSheet.create((theme) => ({
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
@@ -222,7 +222,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<TitlebarDragRegion />
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up</Text>
</View>
@@ -231,7 +232,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<TitlebarDragRegion />
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
@@ -253,7 +255,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
}
return (
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={[styles.container, styles.containerError]}>
<TitlebarDragRegion />
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />

View File

@@ -8,7 +8,6 @@ import {
Platform,
Pressable,
Text,
useColorScheme,
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -63,6 +62,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { isAbsolutePath } from "@/utils/path";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import {
normalizeWorkspaceTabTarget,
@@ -578,8 +578,7 @@ function useCloseTabs(): UseCloseTabsResult {
function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const mainBackgroundColor = theme.colors.surfaceWorkspace;
const toast = useToast();
const isMobile = isCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
@@ -619,7 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
normalizedWorkspaceId.startsWith("/"),
isAbsolutePath(normalizedWorkspaceId),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -688,7 +687,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const { archiveAgent } = useArchiveAgent();
useEffect(() => {
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) {
return;
}
@@ -720,7 +719,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
normalizedWorkspaceId.startsWith("/"),
isAbsolutePath(normalizedWorkspaceId),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -762,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) {
return null;
}
return {
@@ -1140,7 +1139,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (createTerminalMutation.isPending) {
return;
}
if (!normalizedWorkspaceId.startsWith("/")) {
if (!isAbsolutePath(normalizedWorkspaceId)) {
return;
}
createTerminalMutation.mutate(input);
@@ -1367,7 +1366,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!normalizedWorkspaceId.startsWith("/")) {
if (!isAbsolutePath(normalizedWorkspaceId)) {
toast.error("Workspace path not available");
return;
}
@@ -2058,7 +2057,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
disabled={!normalizedWorkspaceId.startsWith("/")}
disabled={!isAbsolutePath(normalizedWorkspaceId)}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
@@ -2329,7 +2328,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
},
headerTitleContainer: {
flex: 1,
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",

View File

@@ -112,11 +112,15 @@ const lightSemanticColors = {
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceWorkspace: "#ffffff", // Workspace main background
// Text
foreground: "#09090b",
foregroundMuted: "#71717a",
// Controls
scrollbarHandle: "#3f3f46", // zinc-700
// Borders - shifted one step lighter
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
@@ -182,11 +186,15 @@ const darkSemanticColors = {
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
// Text
foreground: "#fafafa",
foregroundMuted: "#A1A5A4",
// Controls
scrollbarHandle: "#71717a", // zinc-500
// Borders
border: "#252B2A",
borderAccent: "#2F3534",
@@ -310,18 +318,60 @@ const commonTheme = {
} as const;
export const darkTheme = {
colorScheme: "dark" as const,
colors: {
...darkSemanticColors,
palette: baseColors,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.20)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 8,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.40)",
shadowOffset: { width: 0, height: 12 },
shadowRadius: 24,
elevation: 8,
},
},
...commonTheme,
} as const;
export const lightTheme = {
colorScheme: "light" as const,
colors: {
...lightSemanticColors,
palette: baseColors,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.04)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 16,
elevation: 4,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.08)",
shadowOffset: { width: 0, height: 8 },
shadowRadius: 24,
elevation: 8,
},
},
...commonTheme,
} as const;

View File

@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
describe("readFiniteScreenPoint", () => {
it("returns finite screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
screenX: 1280,
screenY: 720,
});
});
it("rejects NaN screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
});
it("rejects infinite screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
});
it("rejects missing screen coordinates", () => {
expect(readFiniteScreenPoint(undefined)).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
});
});

View File

@@ -1,27 +0,0 @@
export type DesktopWindowScreenPoint = {
screenX: number;
screenY: number;
};
type ScreenPointInput =
| {
screenX?: unknown;
screenY?: unknown;
}
| null
| undefined;
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null;
}
return {
screenX: input.screenX,
screenY: input.screenY,
};
}

View File

@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { isInteractiveDesktopDragTarget } from "./desktop-window";
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
return {
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
} as unknown as EventTarget;
}
describe("isInteractiveDesktopDragTarget", () => {
it("treats focusable pressables as interactive drag exemptions", () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes("[tabindex]"),
});
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
});
it("treats semantic button targets as interactive drag exemptions", () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes("[role='button']"),
});
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
});
it("returns false for non-interactive targets", () => {
const target = createTarget({
matchesSelector: () => false,
});
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
});
it("returns false when the target does not support closest()", () => {
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
});
});

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import { useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import {
getIsElectronRuntimeMac,
getIsElectronRuntime,
@@ -9,120 +9,7 @@ import {
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isElectronRuntime } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
try {
await win.toggleMaximize();
} catch (error) {
console.warn("[DesktopWindow] toggleMaximize failed", error);
}
}
}
// ---------------------------------------------------------------------------
// Manual window dragging via pointer events.
// Mirrors the Tauri implementation: single pointerdown handler with
// double-click-to-maximize via timing, closest() for interactive check,
// and pointer capture for move tracking.
// ---------------------------------------------------------------------------
const INTERACTIVE_SELECTOR =
"button, a, input, textarea, select, " +
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
"[role='menuitem'], [tabindex], [contenteditable='true']";
const DOUBLE_CLICK_MS = 300;
type DesktopDragViewProps = Pick<
ViewProps,
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
>;
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
if (!candidate || typeof candidate.closest !== "function") {
return false;
}
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
}
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isElectronRuntime();
useEffect(() => {
if (!isActive) return;
function handleBlur() {
if (!isDragging.current) return;
isDragging.current = false;
getDesktopWindow()?.endMove?.();
}
window.addEventListener("blur", handleBlur);
return () => window.removeEventListener("blur", handleBlur);
}, [isActive]);
return useMemo((): DesktopDragViewProps => {
if (!isActive) return {};
function stopDrag(e: RNPointerEvent) {
if (!isDragging.current) return;
isDragging.current = false;
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
const el = e.currentTarget as unknown as Element | null;
if (el && "releasePointerCapture" in el) {
el.releasePointerCapture(e.nativeEvent.pointerId);
}
getDesktopWindow()?.endMove?.();
}
return {
onPointerDown: (e: RNPointerEvent) => {
if (e.nativeEvent.button !== 0) return;
if (isInteractiveDesktopDragTarget(e.target)) return;
e.preventDefault();
const now = Date.now();
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
lastPointerDownAt.current = 0;
void toggleMaximize();
return;
}
lastPointerDownAt.current = now;
const win = getDesktopWindow();
if (!win?.startMove) return;
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
if (!screenPoint) return;
isDragging.current = true;
const el = e.currentTarget as unknown as Element;
el.setPointerCapture(e.nativeEvent.pointerId);
win.startMove(screenPoint.screenX, screenPoint.screenY);
},
onPointerMove: (e: RNPointerEvent) => {
if (!isDragging.current) return;
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
if (!screenPoint) {
stopDrag(e);
return;
}
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
},
onPointerUp: stopDrag,
onPointerCancel: stopDrag,
};
}, [isActive]);
}
type RawWindowControlsPadding = {
left: number;

View File

@@ -1,14 +1,10 @@
import { isAbsolutePath } from "./path";
interface BuildAbsoluteExplorerPathInput {
workspaceRoot: string;
entryPath: string;
}
function isAbsolutePath(pathValue: string): boolean {
return (
pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue)
);
}
export function buildAbsoluteExplorerPath({
workspaceRoot,
entryPath,

View File

@@ -1,3 +1,5 @@
import { isAbsolutePath } from "./path";
export interface InlinePathTarget {
raw: string;
path: string;
@@ -183,7 +185,7 @@ export function parseAssistantFileLink(
};
}
if (!trimmed.startsWith("/")) {
if (!isAbsolutePath(trimmed)) {
return null;
}
@@ -195,7 +197,7 @@ export function parseAssistantFileLink(
}
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !normalizedPath.startsWith("/")) {
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -334,7 +336,3 @@ function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
function normalizePathForCompare(value: string): string {
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
}
function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { isAbsolutePath } from "./path";
describe("isAbsolutePath", () => {
it("returns true for Unix absolute paths", () => {
expect(isAbsolutePath("/")).toBe(true);
expect(isAbsolutePath("/home/user")).toBe(true);
expect(isAbsolutePath("/tmp/file.txt")).toBe(true);
});
it("returns true for Windows drive letter paths", () => {
expect(isAbsolutePath("C:\\Users")).toBe(true);
expect(isAbsolutePath("C:/Users")).toBe(true);
expect(isAbsolutePath("d:\\projects")).toBe(true);
});
it("returns true for UNC paths", () => {
expect(isAbsolutePath("\\\\server\\share")).toBe(true);
expect(isAbsolutePath("\\\\\\\\host\\path")).toBe(true);
});
it("returns false for relative paths", () => {
expect(isAbsolutePath("foo/bar")).toBe(false);
expect(isAbsolutePath("./relative")).toBe(false);
expect(isAbsolutePath("../parent")).toBe(false);
expect(isAbsolutePath("")).toBe(false);
expect(isAbsolutePath("file.txt")).toBe(false);
});
it("returns false for edge cases that are not absolute paths", () => {
expect(isAbsolutePath("")).toBe(false);
expect(isAbsolutePath("C:")).toBe(false);
});
it("handles mixed separators in absolute paths", () => {
expect(isAbsolutePath("C:/Users\\mixed/path")).toBe(true);
expect(isAbsolutePath("/tmp\\mixed/path")).toBe(true);
});
});

View File

@@ -0,0 +1,5 @@
export function isAbsolutePath(value: string): boolean {
return (
value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value)
);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.40",
"version": "0.1.41",
"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.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/relay": "0.1.41",
"@getpaseo/server": "0.1.41",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -175,6 +175,7 @@ function checkProviderBinary(binary: string): { path: string | null; version: st
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: process.platform === "win32",
}).trim();
return { path: binaryPath, version: output || null };
} catch {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.40",
"version": "0.1.41",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/cli": "0.1.41",
"@getpaseo/server": "0.1.41",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

View File

@@ -7,6 +7,24 @@ const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"];
const INTERACTIVE_SELECTOR = [
"button",
"a",
"input",
"textarea",
"select",
"[role='button']",
"[role='link']",
"[role='textbox']",
"[role='combobox']",
"[role='tab']",
"[role='switch']",
"[role='checkbox']",
"[role='slider']",
"[role='menuitem']",
"[tabindex]",
"[contenteditable='true']",
].join(", ");
function assert(condition, message) {
if (!condition) {
@@ -28,6 +46,313 @@ async function captureScreenshot(page, fileName) {
return filePath;
}
async function inspectTitlebarRegions(page) {
return page.evaluate((interactiveSelector) => {
const nodes = Array.from(document.querySelectorAll("*"));
const annotationId = "electron-verify-titlebar-style";
const existingAnnotation = document.getElementById(annotationId);
existingAnnotation?.remove();
const annotationStyle = document.createElement("style");
annotationStyle.id = annotationId;
annotationStyle.textContent = `
[data-electron-verify-drag="true"] {
outline: 3px solid #ff4d4f !important;
outline-offset: -3px !important;
}
[data-electron-verify-resizer="true"] {
outline: 3px solid #52c41a !important;
outline-offset: -3px !important;
}
[data-electron-verify-interactive="true"] {
outline: 3px solid #1677ff !important;
outline-offset: -3px !important;
}
`;
document.head.appendChild(annotationStyle);
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}
function summarizeText(element) {
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
}
function readAppRegion(element) {
const style = window.getComputedStyle(element);
return style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
}
function rectInfo(element) {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function summarizeElement(element) {
const style = window.getComputedStyle(element);
return {
tagName: element.tagName.toLowerCase(),
text: summarizeText(element),
appRegion: readAppRegion(element),
position: style.position,
zIndex: style.zIndex,
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
paddingTop: Number.parseFloat(style.paddingTop || "0"),
...rectInfo(element),
};
}
function isNearTop(summary) {
return summary.top < 220;
}
function isTopResizer(element, overlayRect) {
if (!(element instanceof HTMLElement) || !isVisible(element)) {
return false;
}
const summary = summarizeElement(element);
return (
summary.appRegion === "no-drag" &&
summary.position === "absolute" &&
Math.abs(summary.height - 4) <= 1 &&
Math.abs(summary.top - overlayRect.top) <= 2 &&
Math.abs(summary.left - overlayRect.left) <= 2 &&
Math.abs(summary.width - overlayRect.width) <= 2
);
}
function summarizeInteractive(element) {
const summary = summarizeElement(element);
return {
...summary,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
};
}
const dragSummaries = [];
const suspiciousDragHosts = [];
for (const node of nodes) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue;
}
const summary = summarizeElement(node);
if (summary.appRegion !== "drag") {
continue;
}
const parent = node.parentElement instanceof HTMLElement ? node.parentElement : null;
const parentSummary = parent ? summarizeElement(parent) : null;
const interactiveDescendants = Array.from(node.querySelectorAll(interactiveSelector))
.filter((child) => child instanceof HTMLElement)
.filter((child) => isVisible(child))
.map((child) => summarizeInteractive(child));
const siblingResizers = parent
? Array.from(parent.children)
.filter((child) => child !== node)
.filter((child) => child instanceof HTMLElement)
.filter((child) => isTopResizer(child, summary))
.map((child) => summarizeElement(child))
: [];
const parentInteractive = parent
? Array.from(parent.querySelectorAll(interactiveSelector))
.filter((child) => child instanceof HTMLElement)
.filter((child) => isVisible(child))
.map((child) => summarizeInteractive(child))
: [];
const explicitNoDragInteractive = parentInteractive.filter(
(child) => child.appRegion === "no-drag",
);
const record = {
...summary,
parent: parentSummary,
interactiveDescendants: interactiveDescendants.slice(0, 5),
siblingResizers: siblingResizers.slice(0, 3),
explicitNoDragInteractive: explicitNoDragInteractive.slice(0, 5),
parentInteractiveCount: parentInteractive.length,
};
dragSummaries.push(record);
const looksLikeHostShortcut =
isNearTop(summary) &&
(summary.position !== "absolute" ||
summary.text.length > 0 ||
interactiveDescendants.length > 0 ||
parentSummary?.appRegion === "drag");
if (looksLikeHostShortcut) {
suspiciousDragHosts.push(record);
}
}
const verifiedRegions = dragSummaries
.filter((entry) => isNearTop(entry))
.filter((entry) => entry.position === "absolute")
.filter((entry) => entry.text.length === 0)
.filter((entry) => entry.parent?.appRegion !== "drag")
.filter((entry) => entry.siblingResizers.length > 0)
.sort(
(left, right) =>
right.explicitNoDragInteractive.length - left.explicitNoDragInteractive.length ||
left.top - right.top ||
right.width - left.width,
);
const candidate = verifiedRegions[0] ?? null;
if (candidate) {
const matchingDragNode = nodes.find((node) => {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
return false;
}
const summary = summarizeElement(node);
return (
summary.appRegion === "drag" &&
Math.abs(summary.top - candidate.top) <= 1 &&
Math.abs(summary.left - candidate.left) <= 1 &&
Math.abs(summary.width - candidate.width) <= 1 &&
Math.abs(summary.height - candidate.height) <= 1
);
});
if (matchingDragNode instanceof HTMLElement) {
matchingDragNode.setAttribute("data-electron-verify-drag", "true");
const parent = matchingDragNode.parentElement;
if (parent instanceof HTMLElement) {
for (const child of parent.children) {
if (child instanceof HTMLElement && isTopResizer(child, candidate)) {
child.setAttribute("data-electron-verify-resizer", "true");
}
}
const interactiveChildren = Array.from(parent.querySelectorAll(interactiveSelector))
.filter((child) => child instanceof HTMLElement)
.filter((child) => isVisible(child))
.filter((child) => summarizeElement(child).appRegion === "no-drag")
.slice(0, 3);
for (const child of interactiveChildren) {
child.setAttribute("data-electron-verify-interactive", "true");
}
}
}
}
return {
interactiveSelector,
dragRegionCount: dragSummaries.length,
verifiedRegionCount: verifiedRegions.length,
candidate,
suspiciousDragHosts: suspiciousDragHosts.slice(0, 10),
dragRegions: dragSummaries.slice(0, 10),
};
}, INTERACTIVE_SELECTOR);
}
async function inspectFullscreenResizer(page) {
const session = await page.context().newCDPSession(page);
let windowId = null;
let initialBounds = null;
let fullscreenEntered = false;
try {
const windowInfo = await session.send("Browser.getWindowForTarget");
windowId = windowInfo.windowId;
initialBounds = await session.send("Browser.getWindowBounds", { windowId });
await session.send("Browser.setWindowBounds", {
windowId,
bounds: { windowState: "fullscreen" },
});
fullscreenEntered = true;
await page.waitForTimeout(1000);
const details = await page.evaluate(async () => {
const bridge = window.paseoDesktop?.window;
const bridgeFullscreen =
typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null;
const visibleNoDragResizers = Array.from(document.querySelectorAll("*"))
.filter((node) => node instanceof HTMLElement)
.filter((node) => {
const element = node;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const appRegion =
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0" &&
appRegion === "no-drag" &&
style.position === "absolute" &&
Math.abs(rect.height - 4) <= 1 &&
rect.top < 220
);
})
.map((node) => {
const rect = node.getBoundingClientRect();
return {
tagName: node.tagName.toLowerCase(),
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
});
return {
bridgeFullscreen,
visibleNoDragResizers,
};
});
return {
supported: true,
enteredFullscreen: fullscreenEntered,
initialBounds,
...details,
passed:
details.bridgeFullscreen === true &&
Array.isArray(details.visibleNoDragResizers) &&
details.visibleNoDragResizers.length === 0,
};
} catch (error) {
return {
supported: false,
error: String(error),
initialBounds,
};
} finally {
const previousWindowState = initialBounds?.bounds?.windowState ?? "normal";
if (windowId !== null && fullscreenEntered) {
try {
await session.send("Browser.setWindowBounds", {
windowId,
bounds: { windowState: previousWindowState },
});
await page.waitForTimeout(500);
} catch {
// Best-effort restore only.
}
}
await session.detach().catch(() => undefined);
}
}
async function findAppPage(browser) {
for (let attempt = 0; attempt < 30; attempt += 1) {
for (const context of browser.contexts()) {
@@ -132,115 +457,20 @@ async function main() {
await page.waitForTimeout(500);
}
const dragRegionCheck = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll("*"));
const annotationId = "electron-verify-drag-style";
const existingAnnotation = document.getElementById(annotationId);
existingAnnotation?.remove();
const annotationStyle = document.createElement("style");
annotationStyle.id = annotationId;
annotationStyle.textContent = `
[data-electron-verify-drag="true"] {
outline: 3px solid #ff4d4f !important;
outline-offset: -3px !important;
}
`;
document.head.appendChild(annotationStyle);
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}
function summarizeText(element) {
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
}
const regions = [];
let candidate = null;
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
const text = summarizeText(element);
const info = {
tagName: element.tagName.toLowerCase(),
text,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
paddingTop: Number.parseFloat(style.paddingTop || "0"),
};
regions.push(info);
const looksLikeHeader =
rect.top < 180 &&
rect.height >= 40 &&
(text.includes("Settings") || text.includes("Sessions"));
if (!candidate && looksLikeHeader) {
candidate = { ...info };
element.setAttribute("data-electron-verify-drag", "true");
}
}
if (!candidate && regions.length > 0) {
candidate = regions.slice().sort((left, right) => left.top - right.top)[0];
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
if (
Math.abs(rect.top - candidate.top) < 1 &&
Math.abs(rect.left - candidate.left) < 1 &&
Math.abs(rect.width - candidate.width) < 1 &&
Math.abs(rect.height - candidate.height) < 1
) {
element.setAttribute("data-electron-verify-drag", "true");
break;
}
}
}
return {
count: regions.length,
candidate,
regions: regions.slice(0, 10),
};
});
const dragRegionCheck = await inspectTitlebarRegions(page);
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
const dragRegionPassed =
dragRegionCheck.count > 0 &&
dragRegionCheck.dragRegionCount > 0 &&
dragRegionCheck.verifiedRegionCount > 0 &&
Boolean(dragRegionCheck.candidate) &&
dragRegionCheck.candidate.top < 180;
dragRegionCheck.candidate.top < 220 &&
dragRegionCheck.candidate.parent?.appRegion !== "drag" &&
dragRegionCheck.candidate.siblingResizers.length > 0 &&
dragRegionCheck.suspiciousDragHosts.length === 0;
results.push({
check: "drag-regions",
check: "titlebar-drag-structure",
pass: dragRegionPassed,
details: dragRegionCheck,
screenshot: dragScreenshot,
@@ -248,7 +478,7 @@ async function main() {
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
const isMac = process.platform === "darwin";
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null;
const trafficLightPaddingPassed = !isMac
? true
: typeof observedPaddingLeft === "number" &&
@@ -261,12 +491,36 @@ async function main() {
details: {
platform: process.platform,
observedPaddingLeft,
expectedApproximatePaddingLeft: 78,
note: "Traffic-light padding is only validated structurally on macOS in this verifier.",
candidate: dragRegionCheck.candidate,
},
screenshot: trafficLightScreenshot,
});
const noDragInteractiveCheck = {
check: "interactive-no-drag-layering",
pass:
Boolean(dragRegionCheck.candidate) &&
Array.isArray(dragRegionCheck.candidate.explicitNoDragInteractive) &&
dragRegionCheck.candidate.explicitNoDragInteractive.length > 0,
details: {
candidate: dragRegionCheck.candidate,
explicitNoDragInteractive:
dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
},
screenshot: dragScreenshot,
};
results.push(noDragInteractiveCheck);
const fullscreenDetails = await inspectFullscreenResizer(page);
const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png");
results.push({
check: "fullscreen-resizer",
pass: fullscreenDetails.supported ? fullscreenDetails.passed : true,
details: fullscreenDetails,
screenshot: fullscreenScreenshot,
});
const daemonManagementVisible = await Promise.all([
page.getByText("Built-in daemon", { exact: true }).isVisible(),
page.getByText("Daemon management", { exact: true }).isVisible(),

View File

@@ -13,9 +13,11 @@ import {
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import {
registerWindowManager,
getTitleBarOverlayOptions,
getMainWindowChromeOptions,
getWindowBackgroundColor,
resolveSystemWindowTheme,
setupWindowResizeEvents,
setupDefaultContextMenu,
setupDragDropPrevention,
} from "./window/window-manager.js";
import { registerDialogHandlers } from "./features/dialogs.js";
@@ -86,21 +88,19 @@ function applyAppIcon(): void {
}
async function createMainWindow(): Promise<void> {
const isMac = process.platform === "darwin";
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
backgroundColor: getWindowBackgroundColor(systemTheme),
...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: "hidden",
...(isMac
? { trafficLightPosition: { x: 16, y: 14 } }
: {
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
autoHideMenuBar: true,
}),
...getMainWindowChromeOptions({
platform: process.platform,
theme: systemTheme,
}),
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
@@ -109,6 +109,7 @@ async function createMainWindow(): Promise<void> {
});
setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow.show();

View File

@@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
},
window: {
getCurrentWindow: () => ({
startMove: (screenX: number, screenY: number) =>
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
moving: (screenX: number, screenY: number) =>
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
endMove: () => ipcRenderer.send("paseo:window:endMove"),
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
setTitleBarTheme: (theme: "light" | "dark") =>
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
updateWindowControls: (update: {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
handler(payload);

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createWindowMoveState,
readWindowMovePayload,
resolveWindowMovePosition,
} from "./window-drag-coordinates";
describe("window-drag-coordinates", () => {
describe("readWindowMovePayload", () => {
it("returns finite screen coordinates", () => {
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
screenX: 1500,
screenY: 900,
});
});
it("rejects non-finite screen coordinates", () => {
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull();
expect(
readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY }),
).toBeNull();
});
});
describe("createWindowMoveState", () => {
it("derives finite offsets from the window position", () => {
expect(
createWindowMoveState({
payload: { screenX: 1500, screenY: 900 },
windowX: 1200,
windowY: 700,
}),
).toEqual({
offsetX: 300,
offsetY: 200,
});
});
it("rejects non-finite offsets", () => {
expect(
createWindowMoveState({
payload: { screenX: 1500, screenY: 900 },
windowX: Number.NaN,
windowY: 700,
}),
).toBeNull();
});
});
describe("resolveWindowMovePosition", () => {
it("rounds the next window position", () => {
expect(
resolveWindowMovePosition({
payload: { screenX: 1501.4, screenY: 902.6 },
state: { offsetX: 300, offsetY: 200 },
}),
).toEqual({
x: 1201,
y: 703,
});
});
it("rejects non-finite next positions", () => {
expect(
resolveWindowMovePosition({
payload: { screenX: 1500, screenY: 900 },
state: { offsetX: Number.NaN, offsetY: 200 },
}),
).toBeNull();
});
});
});

View File

@@ -1,64 +0,0 @@
export type WindowMovePayload = {
screenX: number;
screenY: number;
};
export type WindowMoveState = {
offsetX: number;
offsetY: number;
};
export type WindowPosition = {
x: number;
y: number;
};
type WindowMovePayloadInput =
| {
screenX?: unknown;
screenY?: unknown;
}
| null
| undefined;
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null;
}
return {
screenX: input.screenX,
screenY: input.screenY,
};
}
export function createWindowMoveState(input: {
payload: WindowMovePayload;
windowX: number;
windowY: number;
}): WindowMoveState | null {
const offsetX = input.payload.screenX - input.windowX;
const offsetY = input.payload.screenY - input.windowY;
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
return null;
}
return { offsetX, offsetY };
}
export function resolveWindowMovePosition(input: {
payload: WindowMovePayload;
state: WindowMoveState;
}): WindowPosition | null {
const x = Math.round(input.payload.screenX - input.state.offsetX);
const y = Math.round(input.payload.screenY - input.state.offsetY);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return { x, y };
}

View File

@@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
import {
applyWindowControlsOverlayUpdate,
createWindowControlsOverlayState,
getMainWindowChromeOptions,
getTitleBarOverlayOptions,
readBadgeCount,
readWindowControlsOverlayUpdate,
readWindowTheme,
resolveRuntimeTitleBarOverlayOptions,
} from "./window-manager";
describe("window-manager", () => {
describe("readBadgeCount", () => {
@@ -39,7 +48,7 @@ describe("window-manager", () => {
expect(getTitleBarOverlayOptions("light")).toEqual({
color: "#ffffff",
symbolColor: "#09090b",
height: 48,
height: 29,
});
});
@@ -47,7 +56,133 @@ describe("window-manager", () => {
expect(getTitleBarOverlayOptions("dark")).toEqual({
color: "#18181c",
symbolColor: "#e4e4e7",
height: 29,
});
});
});
describe("readWindowControlsOverlayUpdate", () => {
it("accepts partial runtime overlay updates", () => {
expect(
readWindowControlsOverlayUpdate({
height: 48,
backgroundColor: "#18181c",
}),
).toEqual({
height: 48,
backgroundColor: "#18181c",
});
});
it("rejects empty and invalid payloads", () => {
expect(readWindowControlsOverlayUpdate(undefined)).toBeNull();
expect(readWindowControlsOverlayUpdate({})).toBeNull();
expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull();
expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull();
});
});
describe("resolveRuntimeTitleBarOverlayOptions", () => {
it("applies the VS Code height minus border adjustment", () => {
expect(
resolveRuntimeTitleBarOverlayOptions({
height: 48,
backgroundColor: "#ffffff",
foregroundColor: "#09090b",
}),
).toEqual({
color: "#ffffff",
symbolColor: "#09090b",
height: 47,
});
});
});
describe("applyWindowControlsOverlayUpdate", () => {
it("merges cached colors with later runtime height updates", () => {
const setTitleBarOverlay = vi.fn();
let state = createWindowControlsOverlayState("dark");
state = applyWindowControlsOverlayUpdate({
win: { setTitleBarOverlay },
current: state,
update: {
backgroundColor: "#18181c",
foregroundColor: "#e4e4e7",
},
});
state = applyWindowControlsOverlayUpdate({
win: { setTitleBarOverlay },
current: state,
update: { height: 48 },
});
expect(state).toEqual({
height: 48,
backgroundColor: "#18181c",
foregroundColor: "#e4e4e7",
});
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(1, {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 28,
});
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(2, {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 47,
});
});
});
describe("getMainWindowChromeOptions", () => {
it("uses frameless hidden title bars with overlay on windows", () => {
expect(
getMainWindowChromeOptions({
platform: "win32",
theme: "dark",
}),
).toEqual({
titleBarStyle: "hidden",
frame: false,
autoHideMenuBar: true,
titleBarOverlay: {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 29,
},
});
});
it("uses frameless hidden title bars with overlay on linux", () => {
expect(
getMainWindowChromeOptions({
platform: "linux",
theme: "light",
}),
).toEqual({
titleBarStyle: "hidden",
frame: false,
autoHideMenuBar: true,
titleBarOverlay: {
color: "#ffffff",
symbolColor: "#09090b",
height: 29,
},
});
});
it("keeps the mac traffic-light path separate", () => {
expect(
getMainWindowChromeOptions({
platform: "darwin",
theme: "dark",
}),
).toEqual({
titleBarStyle: "hidden",
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 14 },
});
});
});

View File

@@ -1,10 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
import {
createWindowMoveState,
readWindowMovePayload,
resolveWindowMovePosition,
type WindowMoveState,
} from "./window-drag-coordinates.js";
import { app, BrowserWindow, Menu, ipcMain, nativeTheme } from "electron";
export function readBadgeCount(input: unknown): number {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
@@ -15,6 +9,17 @@ export function readBadgeCount(input: unknown): number {
}
export type WindowTheme = "light" | "dark";
export type WindowControlsOverlayUpdate = {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
};
export type WindowControlsOverlayState = {
height: number;
backgroundColor?: string;
foregroundColor?: string;
};
export function readWindowTheme(input: unknown): WindowTheme | null {
if (input === "light" || input === "dark") {
@@ -28,71 +33,115 @@ export function resolveSystemWindowTheme(): WindowTheme {
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
}
export function getWindowBackgroundColor(theme: WindowTheme): string {
return theme === "dark" ? "#181B1A" : "#ffffff";
}
export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState {
const overlay = getTitleBarOverlayOptions(theme);
return {
height: overlay.height ?? 29,
backgroundColor: overlay.color,
foregroundColor: overlay.symbolColor,
};
}
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
if (theme === "dark") {
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
return { color: "#181B1A", symbolColor: "#e4e4e7", height: 29 };
}
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
return { color: "#ffffff", symbolColor: "#09090b", height: 29 };
}
export function getMainWindowChromeOptions(input: {
platform: NodeJS.Platform;
theme: WindowTheme;
}): Pick<
Electron.BrowserWindowConstructorOptions,
"titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar"
> {
if (input.platform === "darwin") {
return {
titleBarStyle: "hidden",
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 14 },
};
}
return {
titleBarStyle: "hidden",
frame: false,
titleBarOverlay: getTitleBarOverlayOptions(input.theme),
autoHideMenuBar: true,
};
}
function readFiniteOverlayHeight(input: unknown): number | null {
if (typeof input !== "number" || !Number.isFinite(input)) {
return null;
}
const rounded = Math.round(input);
return rounded >= 1 ? rounded : null;
}
function readOverlayColor(input: unknown): string | null {
if (typeof input !== "string") {
return null;
}
return input;
}
export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null {
if (!input || typeof input !== "object") {
return null;
}
const candidate = input as Record<string, unknown>;
const height = readFiniteOverlayHeight(candidate.height);
const backgroundColor = readOverlayColor(candidate.backgroundColor);
const foregroundColor = readOverlayColor(candidate.foregroundColor);
if (height === null && backgroundColor === null && foregroundColor === null) {
return null;
}
return {
...(height !== null ? { height } : {}),
...(backgroundColor !== null ? { backgroundColor } : {}),
...(foregroundColor !== null ? { foregroundColor } : {}),
};
}
export function resolveRuntimeTitleBarOverlayOptions(
state: WindowControlsOverlayState,
): Electron.TitleBarOverlayOptions {
return {
color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor,
symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor,
height: Math.max(0, state.height - 1),
};
}
export function applyWindowControlsOverlayUpdate(input: {
win: Pick<BrowserWindow, "setTitleBarOverlay">;
current: WindowControlsOverlayState;
update: WindowControlsOverlayUpdate;
}): WindowControlsOverlayState {
const next: WindowControlsOverlayState = {
height: input.update.height ?? input.current.height,
backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor,
foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor,
};
input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next));
return next;
}
export function registerWindowManager(): void {
// ---------------------------------------------------------------------------
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
// State is keyed per window id to support multiple windows.
// ---------------------------------------------------------------------------
const moveStates = new Map<number, WindowMoveState>();
ipcMain.on("paseo:window:startMove", (event, payload: { screenX: number; screenY: number }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const movePayload = readWindowMovePayload(payload);
if (!movePayload) {
moveStates.delete(win.id);
return;
}
const [winX, winY] = win.getPosition();
const moveState = createWindowMoveState({
payload: movePayload,
windowX: winX,
windowY: winY,
});
if (!moveState) {
moveStates.delete(win.id);
return;
}
moveStates.set(win.id, moveState);
});
ipcMain.on("paseo:window:moving", (event, payload: { screenX: number; screenY: number }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const state = moveStates.get(win.id);
if (!state) return;
const movePayload = readWindowMovePayload(payload);
if (!movePayload) {
moveStates.delete(win.id);
return;
}
const nextPosition = resolveWindowMovePosition({
payload: movePayload,
state,
});
if (!nextPosition) {
moveStates.delete(win.id);
return;
}
win.setPosition(nextPosition.x, nextPosition.y);
});
ipcMain.on("paseo:window:endMove", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) moveStates.delete(win.id);
});
app.on("browser-window-created", (_event, win) => {
win.on("closed", () => moveStates.delete(win.id));
});
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
@@ -124,18 +173,33 @@ export function registerWindowManager(): void {
}
});
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || process.platform === "darwin") {
if (!win) {
return;
}
const nextTheme = readWindowTheme(theme);
if (!nextTheme) {
const nextUpdate = readWindowControlsOverlayUpdate(update);
if (!nextUpdate) {
return;
}
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
if (nextUpdate.backgroundColor) {
win.setBackgroundColor(nextUpdate.backgroundColor);
}
if (process.platform === "darwin") {
return;
}
const current =
overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme());
const nextState = applyWindowControlsOverlayUpdate({
win,
current,
update: nextUpdate,
});
overlayStateByWindow.set(win, nextState);
});
}
@@ -153,6 +217,18 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
});
}
export function setupDefaultContextMenu(win: BrowserWindow): void {
win.webContents.on("context-menu", (_event, params) => {
const menu = Menu.buildFromTemplate([
{ role: "copy", enabled: params.selectionText.length > 0 },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
]);
menu.popup({ window: win });
});
}
/**
* Prevent Electron from navigating to files dragged onto the window.
* The renderer handles drag-drop via standard HTML5 APIs instead.

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.40",
"version": "0.1.41",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.40",
"version": "0.1.41",
"type": "module",
"publishConfig": {
"access": "public"

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.40",
"version": "0.1.41",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -63,8 +63,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/relay": "0.1.40",
"@getpaseo/highlight": "0.1.41",
"@getpaseo/relay": "0.1.41",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",

View File

@@ -2,7 +2,7 @@ import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
const DEFAULT_MAX_ITEMS = 40;
const DEFAULT_MAX_ITEMS = 0;
const MAX_TOOL_INPUT_CHARS = 400;
const MAX_TOOL_SUMMARY_CHARS = 200;

View File

@@ -168,6 +168,24 @@ describe("findExecutable", () => {
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
}
if (command === "where.exe") {
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("uses the last line from login-shell which output", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue(

View File

@@ -1,6 +1,7 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import path from "node:path";
import { shellEnvSync } from "shell-env";
import { z } from "zod";
@@ -106,7 +107,7 @@ function resolveExecutableFromWhichOutput(
return null;
}
if (!candidate.startsWith("/")) {
if (!path.isAbsolute(candidate)) {
console.warn(
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
);
@@ -187,7 +188,10 @@ export function applyProviderEnv(
* user opened a terminal and typed the command. If that fails (e.g. the login
* shell itself errors) we fall back to a plain `which`.
*
* On Windows the system PATH is always available, so `where.exe` is sufficient.
* On Windows we augment the daemon PATH with machine/user registry PATH values
* and return the first `where.exe` match. Launch-time execution decides whether
* the resolved path needs `cmd.exe` semantics (for example npm shims under
* nvm4w such as `C:\nvm4w\nodejs\codex`).
*/
export function findExecutable(
name: string,
@@ -228,8 +232,12 @@ export function findExecutable(
Path: resolvedPath,
};
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
const firstLine = out.split(/\r?\n/)[0]?.trim();
return firstLine || null;
return (
out
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? null
);
} catch {
return null;
}

View File

@@ -219,6 +219,7 @@ function applyRuntimeSettingsToClaudeOptions(
...applyProviderEnv(spawnOptions.env, runtimeSettings),
...(launchEnv ?? {}),
},
shell: process.platform === "win32",
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
});

View File

@@ -3421,6 +3421,7 @@ export class CodexAppServerAgentClient implements AgentClient {
);
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
detached: process.platform !== "win32",
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
});

View File

@@ -336,6 +336,7 @@ export class OpenCodeServerManager {
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)],
{
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings),
},

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import {
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
type TerminalSession,
} from "./terminal.js";
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -111,6 +112,18 @@ describe("Terminal", () => {
expect(statSync(helperPath).mode & 0o111).toBe(0o111);
});
it("uses cmd.exe-compatible default shell on Windows", () => {
expect(resolveDefaultTerminalShell({ platform: "win32", env: {} })).toBe(
"C:\\Windows\\System32\\cmd.exe",
);
expect(
resolveDefaultTerminalShell({
platform: "win32",
env: { ComSpec: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" },
}),
).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
});
it("creates a terminal session with an id, name, and cwd", async () => {
const session = trackSession(
await createTerminal({

View File

@@ -119,6 +119,22 @@ export function ensureNodePtySpawnHelperExecutableForCurrentPlatform(
}
}
export function resolveDefaultTerminalShell(
options: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
} = {},
): string {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
if (platform === "win32") {
return env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
}
return env.SHELL || "/bin/sh";
}
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
const buffer = terminal.buffer.active;
const line = buffer.getLine(row);
@@ -299,12 +315,13 @@ export function captureTerminalLines(
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
shell = process.env.SHELL || "/bin/sh",
shell,
env = {},
rows = 24,
cols = 80,
name = "Terminal",
} = options;
const resolvedShell = shell ?? resolveDefaultTerminalShell();
const id = randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
@@ -324,7 +341,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
// Create PTY
const ptyProcess = pty.spawn(shell, [], {
const ptyProcess = pty.spawn(resolvedShell, [], {
name: "xterm-256color",
cols,
rows,

View File

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

View File

@@ -0,0 +1,49 @@
import { getReleaseInfoFromSourceTag } from "./release-version-utils.mjs";
function usageAndExit(code = 1) {
process.stderr.write(`Usage: node scripts/emit-release-env.mjs --source-tag <tag>\n`);
process.exit(code);
}
function parseArgs(argv) {
let sourceTag = "";
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--source-tag") {
sourceTag = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
usageAndExit(0);
}
usageAndExit();
}
if (!sourceTag) {
usageAndExit();
}
return sourceTag;
}
const sourceTag = parseArgs(process.argv.slice(2));
const info = getReleaseInfoFromSourceTag(sourceTag);
const entries = [
["SOURCE_TAG", info.sourceTag],
["RELEASE_TAG", info.releaseTag],
["RELEASE_VERSION", info.version],
["RELEASE_BASE_VERSION", info.baseVersion],
["RELEASE_PRERELEASE", info.prerelease ?? ""],
["IS_PRERELEASE", info.isPrerelease ? "true" : "false"],
["IS_RELEASE_CANDIDATE", info.isReleaseCandidate ? "true" : "false"],
["RELEASE_TYPE", info.releaseType],
["DESKTOP_VERSION", info.version],
["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"],
];
for (const [key, value] of entries) {
process.stdout.write(`${key}=${value}\n`);
}

View File

@@ -1,66 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const rootPackagePath = path.join(rootDir, "package.json");
function usageAndExit(code = 0) {
process.stderr.write(`Usage: node scripts/finalize-current-release.mjs\n`);
process.exit(code);
}
function run(cmd, args) {
execFileSync(cmd, args, { cwd: rootDir, stdio: "inherit" });
}
function runQuiet(cmd, args) {
return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim();
}
function parseRepoSlug(remoteUrl) {
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)$/);
if (httpsMatch) {
return httpsMatch[1];
}
throw new Error(`Could not determine GitHub repo from origin URL: ${remoteUrl}`);
}
if (process.argv.length > 2) {
const arg = process.argv[2];
if (arg === "--help" || arg === "-h") {
usageAndExit(0);
}
usageAndExit(1);
}
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
if (!version) {
throw new Error('Root package.json must contain a valid "version"');
}
const tag = `v${version}`;
const repo = parseRepoSlug(runQuiet("git", ["remote", "get-url", "origin"]));
const releaseJson = runQuiet("gh", [
"release",
"view",
tag,
"--repo",
repo,
"--json",
"isDraft,tagName,url",
]);
const release = JSON.parse(releaseJson);
if (!release.isDraft) {
throw new Error(`Release ${tag} is already published. Refusing to finalize a non-draft release.`);
}
run("npm", ["run", "release:publish"]);
run("gh", ["release", "edit", tag, "--repo", repo, "--draft=false"]);
console.log(`Finalized release ${tag}: npm packages published and GitHub draft release promoted.`);

View File

@@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, "..");
const rootPackagePath = path.join(rootDir, "package.json");
function usageAndExit(code = 0) {
process.stderr.write(`Usage: node scripts/push-current-release-tag.mjs [--draft-release]\n`);
process.stderr.write(`Usage: node scripts/push-current-release-tag.mjs [--branch <name>]\n`);
process.exit(code);
}
@@ -20,12 +20,32 @@ function runQuiet(cmd, args) {
return execFileSync(cmd, args, { cwd: rootDir, encoding: "utf8" }).trim();
}
function parseArgs(argv) {
const args = { draftRelease: false };
function getRemoteTagCommit(tag) {
try {
return runQuiet("git", ["ls-remote", "--exit-code", "--refs", "origin", `refs/tags/${tag}^{}`])
.split(/\s+/)[0]
.trim();
} catch {
try {
return runQuiet("git", ["ls-remote", "--exit-code", "--refs", "origin", `refs/tags/${tag}`])
.split(/\s+/)[0]
.trim();
} catch {
return "";
}
}
}
for (const arg of argv) {
if (arg === "--draft-release") {
args.draftRelease = true;
function parseArgs(argv) {
const args = {
branch: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--branch") {
args.branch = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
@@ -37,22 +57,13 @@ function parseArgs(argv) {
return args;
}
function parseRepoSlug(remoteUrl) {
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)$/);
if (httpsMatch) {
return httpsMatch[1];
}
throw new Error(`Could not determine GitHub repo from origin URL: ${remoteUrl}`);
}
const args = parseArgs(process.argv.slice(2));
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
const version = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
if (!version) {
throw new Error('Root package.json must contain a valid "version"');
}
const args = parseArgs(process.argv.slice(2));
const tag = `v${version}`;
const headCommit = runQuiet("git", ["rev-parse", "HEAD"]);
@@ -71,33 +82,25 @@ if (localTagCommit !== headCommit) {
);
}
run("git", ["push", "origin", "HEAD"]);
let remoteTagExists = false;
try {
runQuiet("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`]);
remoteTagExists = true;
} catch {
remoteTagExists = false;
const currentBranch = runQuiet("git", ["branch", "--show-current"]);
const branchRef = args.branch || currentBranch;
if (!branchRef) {
throw new Error("Cannot determine branch to push. Pass --branch <name>.");
}
run("git", ["push", "origin", `HEAD:${branchRef}`]);
if (remoteTagExists) {
const remoteTagCommit = getRemoteTagCommit(tag);
if (remoteTagCommit) {
if (remoteTagCommit !== localTagCommit) {
throw new Error(
`Remote tag ${tag} points to ${remoteTagCommit}, but local tag points to ${localTagCommit}. ` +
"Refusing to reuse an existing release tag for a different commit.",
);
}
console.log(`Tag ${tag} already exists on origin`);
} else {
run("git", ["push", "origin", tag]);
}
if (args.draftRelease) {
const repo = parseRepoSlug(runQuiet("git", ["remote", "get-url", "origin"]));
run("node", [
path.join("scripts", "sync-release-notes-from-changelog.mjs"),
"--repo",
repo,
"--tag",
tag,
"--create-if-missing",
"--draft",
]);
}
console.log(`Release push complete: branch HEAD and tag ${tag}`);

View File

@@ -0,0 +1,166 @@
const versionPattern = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?$/;
const sourceTagPattern =
/^(?:(?:desktop(?:-(?:windows|linux|macos))?|android)-)?v(?<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
function assertInteger(value, label) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`Invalid ${label}: ${value}`);
}
}
export function parseReleaseVersion(version) {
const trimmed = version.trim();
const match = trimmed.match(versionPattern);
if (!match?.groups) {
throw new Error(
`Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-rc.1.`,
);
}
const major = Number.parseInt(match.groups.major, 10);
const minor = Number.parseInt(match.groups.minor, 10);
const patch = Number.parseInt(match.groups.patch, 10);
const prerelease = match.groups.prerelease ?? null;
const rcMatch = prerelease?.match(/^rc\.(?<rc>\d+)$/) ?? null;
const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null;
assertInteger(major, "major version");
assertInteger(minor, "minor version");
assertInteger(patch, "patch version");
if (rcNumber !== null) {
assertInteger(rcNumber, "release candidate number");
}
return {
version: trimmed,
major,
minor,
patch,
prerelease,
baseVersion: `${major}.${minor}.${patch}`,
isPrerelease: prerelease !== null,
isReleaseCandidate: rcNumber !== null,
rcNumber,
};
}
export function formatReleaseVersion({ major, minor, patch, prerelease = null }) {
assertInteger(major, "major version");
assertInteger(minor, "minor version");
assertInteger(patch, "patch version");
return prerelease ? `${major}.${minor}.${patch}-${prerelease}` : `${major}.${minor}.${patch}`;
}
export function normalizeReleaseTag(rawTag) {
const trimmed = rawTag.trim().replace(/^refs\/tags\//, "");
const match = trimmed.match(sourceTagPattern);
if (!match?.groups?.version) {
throw new Error(
`Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-rc.N, desktop-v..., or android-v...`,
);
}
return `v${match.groups.version}`;
}
export function getReleaseInfoFromSourceTag(sourceTag) {
const releaseTag = normalizeReleaseTag(sourceTag);
const parsed = parseReleaseVersion(releaseTag.slice(1));
return {
sourceTag,
releaseTag,
version: parsed.version,
baseVersion: parsed.baseVersion,
prerelease: parsed.prerelease,
isPrerelease: parsed.isPrerelease,
isReleaseCandidate: parsed.isReleaseCandidate,
rcNumber: parsed.rcNumber,
releaseType: parsed.isPrerelease ? "prerelease" : "release",
isSmokeTag: sourceTag.includes("gha-smoke"),
};
}
export function computeNextReleaseVersion(currentVersion, mode) {
const parsed = parseReleaseVersion(currentVersion);
if (mode === "patch" || mode === "minor" || mode === "major") {
if (parsed.isPrerelease) {
throw new Error(
`Cannot cut a stable ${mode} release from prerelease version ${currentVersion}. Promote it first.`,
);
}
if (mode === "patch") {
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch + 1,
});
}
if (mode === "minor") {
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor + 1,
patch: 0,
});
}
return formatReleaseVersion({
major: parsed.major + 1,
minor: 0,
patch: 0,
});
}
if (mode === "rc-patch" || mode === "rc-minor" || mode === "rc-major") {
if (parsed.isPrerelease) {
throw new Error(
`Cannot start a new RC line from prerelease version ${currentVersion}. Use rc-next or promote.`,
);
}
if (mode === "rc-patch") {
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch + 1,
prerelease: "rc.1",
});
}
if (mode === "rc-minor") {
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor + 1,
patch: 0,
prerelease: "rc.1",
});
}
return formatReleaseVersion({
major: parsed.major + 1,
minor: 0,
patch: 0,
prerelease: "rc.1",
});
}
if (mode === "rc-next") {
if (!parsed.isReleaseCandidate || parsed.rcNumber === null) {
throw new Error(
`Cannot advance RC number from ${currentVersion}. Expected a version like 0.1.41-rc.1.`,
);
}
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
prerelease: `rc.${parsed.rcNumber + 1}`,
});
}
if (mode === "promote") {
if (!parsed.isReleaseCandidate) {
throw new Error(
`Cannot promote ${currentVersion}. Expected a release candidate version like 0.1.41-rc.1.`,
);
}
return parsed.baseVersion;
}
throw new Error(`Unsupported release mode "${mode}".`);
}

View File

@@ -0,0 +1,74 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { computeNextReleaseVersion } from "./release-version-utils.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const rootPackagePath = path.join(rootDir, "package.json");
function usageAndExit(code = 1) {
process.stderr.write(`Usage: node scripts/set-release-version.mjs --mode <mode> [--print]\n`);
process.stderr.write(
"Modes: patch, minor, major, rc-patch, rc-minor, rc-major, rc-next, promote\n",
);
process.exit(code);
}
function parseArgs(argv) {
const args = {
mode: "",
print: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--mode") {
args.mode = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--print") {
args.print = true;
continue;
}
if (arg === "--help" || arg === "-h") {
usageAndExit(0);
}
usageAndExit();
}
if (!args.mode) {
usageAndExit();
}
return args;
}
const args = parseArgs(process.argv.slice(2));
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
const currentVersion = typeof rootPackage.version === "string" ? rootPackage.version.trim() : "";
if (!currentVersion) {
throw new Error('Root package.json must contain a valid "version".');
}
const nextVersion = computeNextReleaseVersion(currentVersion, args.mode);
if (args.print) {
process.stdout.write(`${nextVersion}\n`);
process.exit(0);
}
execFileSync(
"npm",
[
"version",
nextVersion,
"--include-workspace-root",
"--message",
"chore(release): cut %s",
],
{ cwd: rootDir, stdio: "inherit" },
);

View File

@@ -2,6 +2,11 @@ import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
getReleaseInfoFromSourceTag,
normalizeReleaseTag,
parseReleaseVersion,
} from "./release-version-utils.mjs";
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
@@ -13,7 +18,6 @@ Options:
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
--tag <tag> Release tag (e.g. v0.1.14). Defaults to latest changelog entry.
--create-if-missing Create release if it does not already exist.
--draft Create missing release as draft.
`;
process.stderr.write(usage.trimStart());
process.stderr.write("\n");
@@ -25,7 +29,6 @@ function parseArgs(argv) {
repo: process.env.GITHUB_REPOSITORY || "",
tag: "",
createIfMissing: false,
draft: false,
};
for (let index = 0; index < argv.length; index += 1) {
@@ -54,12 +57,6 @@ function parseArgs(argv) {
args.createIfMissing = true;
continue;
}
if (arg === "--draft") {
args.draft = true;
continue;
}
usageAndExit();
}
@@ -70,11 +67,6 @@ function parseArgs(argv) {
return args;
}
function normalizeTag(rawTag) {
const trimmed = rawTag.trim().replace(/^refs\/tags\//, "");
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
}
function parseChangelog(changelogText) {
const lines = changelogText.split(/\r?\n/);
const headings = [];
@@ -140,23 +132,51 @@ function runGh(args) {
execFileSync("gh", args, { stdio: "inherit" });
}
function buildPrereleaseNotes({ releaseTag, version }) {
const today = new Date().toISOString().slice(0, 10);
const lines = [
`## ${version} - ${today}`,
"",
"Release candidate build for testing from `main`.",
"",
`- Tag: \`${releaseTag}\``,
];
if (process.env.GITHUB_SHA) {
lines.push(`- Commit: \`${process.env.GITHUB_SHA}\``);
}
lines.push("- This prerelease does not replace the website's latest stable download.");
return `${lines.join("\n")}\n`;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const changelogPath = path.resolve("CHANGELOG.md");
const changelogText = readFileSync(changelogPath, "utf8");
const entries = parseChangelog(changelogText);
const targetTag = args.tag ? normalizeTag(args.tag) : entries[0].tag;
const targetTag = args.tag ? normalizeReleaseTag(args.tag) : entries[0].tag;
const releaseInfo = getReleaseInfoFromSourceTag(targetTag);
const targetEntry = entries.find((entry) => entry.tag === targetTag);
if (!targetEntry) {
let notes = targetEntry?.notes ?? null;
if (!notes && releaseInfo.isPrerelease) {
notes = buildPrereleaseNotes({
releaseTag: releaseInfo.releaseTag,
version: releaseInfo.version,
});
}
if (!notes) {
console.log(`No matching changelog section found for ${targetTag}. Skipping.`);
return;
}
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
writeFileSync(notesPath, targetEntry.notes);
writeFileSync(notesPath, notes);
try {
if (hasRelease(targetTag, args.repo)) {
@@ -184,7 +204,7 @@ function main() {
"--notes-file",
notesPath,
"--verify-tag",
...(args.draft ? ["--draft"] : []),
...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []),
]);
console.log(`Created release ${targetTag} with changelog notes.`);
} catch (createError) {