Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e529c3601f fix(server): drop Claude permission skip allowance
Fixes #1582.
2026-06-20 13:45:42 +07:00
471 changed files with 15183 additions and 46916 deletions
-21
View File
@@ -1,21 +0,0 @@
.git
.dev
.playwright-mcp
.paseo
.tasks
.wrangler
**/.wrangler
**/.tanstack
**/node_modules
**/dist
**/build
**/.cache
**/.expo
**/test-results
**/*.tsbuildinfo
artifacts
packages/app/android
packages/desktop/release
plan.*.log
*.log
CLAUDE.local.md
-141
View File
@@ -1,141 +0,0 @@
name: Docker
on:
pull_request:
branches: [main]
paths:
- "docker/**"
- ".github/workflows/docker.yml"
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
inputs:
paseo_version:
description: "Paseo npm version to build for validation. Defaults to latest."
required: false
default: ""
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
REGISTRY: ghcr.io
PLATFORMS: linux/amd64,linux/arm64
jobs:
setup:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.values.outputs.image }}
install_version: ${{ steps.values.outputs.install_version }}
publish: ${{ steps.values.outputs.publish }}
source_build: ${{ steps.values.outputs.source_build }}
check_tag: ${{ steps.values.outputs.check_tag }}
publish_tags: ${{ steps.values.outputs.publish_tags }}
steps:
- uses: actions/checkout@v6
- id: values
env:
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
REPO_OWNER: ${{ github.repository_owner }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/paseo"
install_version="${INPUT_PASEO_VERSION:-latest}"
publish=false
source_build=false
prerelease=false
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
install_version="${REF_NAME#v}"
publish=true
if [[ "${REF_NAME}" == *-* ]]; then
prerelease=true
source_build=true
fi
fi
check_tag="${image}:check-${GITHUB_SHA::12}"
if [[ "${prerelease}" == "true" ]]; then
publish_tags="${image}:${install_version}"
else
publish_tags="${image}:${install_version}"$'\n'"${image}:latest"
fi
{
echo "image=${image}"
echo "install_version=${install_version}"
echo "publish=${publish}"
echo "source_build=${source_build}"
echo "check_tag=${check_tag}"
echo "publish_tags<<EOF"
echo "${publish_tags}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
build:
needs: setup
if: needs.setup.outputs.publish != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
tags: ${{ needs.setup.outputs.check_tag }}
push: false
provenance: false
cache-from: type=gha,scope=paseo
cache-to: type=gha,scope=paseo,mode=max
publish:
needs: setup
if: needs.setup.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
tags: ${{ needs.setup.outputs.publish_tags }}
push: true
provenance: false
cache-from: type=gha,scope=paseo
cache-to: type=gha,scope=paseo,mode=max
-116
View File
@@ -1,121 +1,5 @@
# Changelog
## 0.1.102-beta.1 - 2026-06-27
### Added
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
### Improved
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- ACP sessions load with the correct project folder and MCP server settings ([#1624](https://github.com/getpaseo/paseo/pull/1624) by [@theslava](https://github.com/theslava))
- Docker images serve the bundled web UI from global installs
- Docker web UI connections work when opened through either `localhost` or `127.0.0.1`
- Docker images can extract downloaded local speech models
## 0.1.101 - 2026-06-26
### Added
- Copy a troubleshooting report from Settings when support needs host, daemon, provider, and log details ([#1728](https://github.com/getpaseo/paseo/pull/1728))
- Claude image tool results now render as images in chat ([#1717](https://github.com/getpaseo/paseo/pull/1717))
- Added Japanese ([#1694](https://github.com/getpaseo/paseo/pull/1694) by [@sysCat64](https://github.com/sysCat64))
- Added Brazilian Portuguese ([#1653](https://github.com/getpaseo/paseo/pull/1653) by [@Alcimerio](https://github.com/Alcimerio))
### Improved
- Provider diagnostics stay useful even when model discovery is slow ([#1724](https://github.com/getpaseo/paseo/pull/1724))
- Slow provider requests no longer make the app look disconnected ([#1723](https://github.com/getpaseo/paseo/pull/1723))
- Worktrees linked to differently named tracked branches find their PRs correctly ([#1718](https://github.com/getpaseo/paseo/pull/1718))
- Workspaces started from slash-command prompts get clearer names ([#1709](https://github.com/getpaseo/paseo/pull/1709))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Pi no longer creates empty sessions while loading new-agent options ([#1727](https://github.com/getpaseo/paseo/pull/1727))
- Windows daemon status finds the daemon process more reliably ([#1725](https://github.com/getpaseo/paseo/pull/1725))
- OpenAI voice credentials no longer affect other OpenAI-backed tools
- Provider model lists no longer disappear during refresh
## 0.1.100 - 2026-06-24
### Added
- Cycle agent modes with Shift+Tab
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
## 0.1.99 - 2026-06-23
### Improved
- The PR panel now has a refresh button and clearer loading states ([#1664](https://github.com/getpaseo/paseo/pull/1664))
- Provider diagnostics and model lists now stay in sync ([#1660](https://github.com/getpaseo/paseo/pull/1660))
### Fixed
- ACP providers like Grok no longer show duplicate user messages
- Saved composer modes no longer reset while provider data is loading ([#1658](https://github.com/getpaseo/paseo/pull/1658))
- The right sidebar no longer gets stuck on mobile ([#1661](https://github.com/getpaseo/paseo/pull/1661))
## 0.1.98 - 2026-06-21
### Added
- See plan usage in-app for Claude, Codex, Copilot, Cursor, Z.AI, Grok, and Kimi ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati))
- Added Ultracode for Claude ([#1625](https://github.com/getpaseo/paseo/pull/1625))
- Detach a subagent to run it on its own ([#1612](https://github.com/getpaseo/paseo/pull/1612))
- Add a project without creating a workspace
- Add a setting to show branch names instead of titles in the sidebar
### Improved
- Mid-turn thinking and mode changes now say they apply next turn
- PR merge options name their method: squash, merge, or rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger))
- A running agent's mode change is remembered for new agents
- Copy a provider's launch diagnostic in one tap ([#1611](https://github.com/getpaseo/paseo/pull/1611))
### Fixed
- OpenCode no longer scans your whole disk on macOS desktop ([#1626](https://github.com/getpaseo/paseo/pull/1626))
- Daemon no longer crashes when OpenAI speech has no API key ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger))
- Reopening an archived Codex agent no longer hangs
- Claude's context meter no longer jumps to subagent usage
- Claude's context meter fills from the first message in a new session
- OpenCode's mode picker now respects your disabled modes ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger))
- File links and @-mentions find files in dot-folders and deep paths ([#1609](https://github.com/getpaseo/paseo/pull/1609))
- Archiving a project's last workspace no longer makes it vanish ([#1631](https://github.com/getpaseo/paseo/pull/1631))
- Collapsed sidebar projects stay collapsed
## 0.1.97 - 2026-06-18
### Added
-1
View File
@@ -44,7 +44,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
+8 -32
View File
@@ -1,28 +1,14 @@
# Contributing to Paseo
Paseo is an opinionated product maintained by one person right now.
Paseo is an opinionated product maintained by one person.
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
I read every issue and PR myself, and I am selective about what contributions I accept.
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
## Becoming a maintainer
Core product, design, architecture, and workflow changes are not accepted.
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
Here's the progression:
1. Get involved in the community: answer questions in Discord and on GitHub
2. Triage bugs: replicate and help fix them
3. Work on maintainer-approved features
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
## Pull requests
✅ Will be accepted
Follow these rules if you want your PR to be merged:
- Keep it to one focused change
- Link to an issue
@@ -32,25 +18,15 @@ Learning on the job is fine, I do not care how many years of experience you have
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
⛔️ Will be rejected
Your PR will be closed if you do any of these:
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Add a feature or design change that wasn't discussed first
- Make product, design, or architecture changes without prior discussion
- Submit no evidence of testing
- Skip the linked issue
- Clearly fully AI-generated PR
## Requesting features
If you need a feature implemented, create a Github issue or a thread in Discord.
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
## AI assistance
Using AI to help write code is fine, but you must:
- Ensure your agents read the docs
- Understand the code you submit
- Review and test the code yourself
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
-172
View File
@@ -1,172 +0,0 @@
<p align="center">
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo logo">
</p>
<h1 align="center">Paseo</h1>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">Claude Code、Codex、Copilot、OpenCode、Pi のエージェントを、ひとつのインターフェースで。</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo アプリのスクリーンショット" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo モバイルアプリ" width="100%">
</p>
> [!NOTE]
> 私はひとりでメンテナンスしているため、GitHub Issues を毎日確認できるとは限りません。
> 急ぎの問題や作業がブロックされている場合は、[Discord](https://discord.gg/jz8T2uahpH) から連絡するのが一番早いです。
---
自分のマシンでエージェントを並列実行。スマートフォンからでもデスクからでも、開発を進めてリリースできます。
- **セルフホスト:** エージェントはあなたのマシン上で動作し、完全な開発環境を使用します。自分のツール・設定・スキルをそのまま活用できます。
- **マルチプロバイダー:** Claude Code、Codex、Copilot、OpenCode、Pi を同一のインターフェースで利用。タスクに合ったモデルを選べます。
- **音声コントロール:** 音声モードでタスクを口述したり問題を話し合ったりできます。ハンズフリーが必要なときに便利です。
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
## はじめかた
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
### 前提条件
エージェント CLI をひとつ以上インストールし、認証情報を設定しておく必要があります。
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### デスクトップアプリ(推奨)
[paseo.sh/download](https://paseo.sh/download) または [GitHub のリリースページ](https://github.com/getpaseo/paseo/releases)からダウンロードしてください。アプリを開くとデーモンが自動的に起動します。追加のインストールは不要です。
スマートフォンから接続するには、Settings 画面に表示される QR コードをスキャンしてください。
### CLI / ヘッドレス
CLI をインストールして Paseo を起動します。
```bash
npm install -g @getpaseo/cli
paseo
```
ターミナルに QR コードが表示されます。どのクライアントからでも接続できます。サーバーやリモートマシンでの利用に適しています。
詳しいセットアップと設定については以下を参照してください。
- [ドキュメント](https://paseo.sh/docs)
- [設定リファレンス](https://paseo.sh/docs/configuration)
## CLI
アプリでできることはすべてターミナルからも実行できます。
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # 実行中のエージェントを一覧表示
paseo attach abc123 # ライブ出力をストリーミング
paseo send abc123 "also add tests" # 追加タスクを送信
# リモートデーモンで実行
paseo --host workstation.local:6767 run "run the full test suite"
```
詳細は[完全な CLI リファレンス](https://paseo.sh/docs/cli)を参照してください。
## スキル
スキルはエージェントに Paseo を使って他のエージェントをオーケストレーションする方法を教えます。
```bash
npx skills add getpaseo/paseo
```
どのエージェントとの会話でも使用できます。
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
## 開発
モノレポのパッケージ構成:
- `packages/server`: Paseo デーモン(エージェントプロセスのオーケストレーション、WebSocket API、MCP サーバー)
- `packages/app`: Expo クライアント(iOS、Android、Web)
- `packages/cli`: デーモンおよびエージェントワークフロー向け `paseo` CLI
- `packages/desktop`: Electron デスクトップアプリ
- `packages/relay`: リモート接続用リレーパッケージ
- `packages/website`: マーケティングサイトとドキュメント(`paseo.sh`)
よく使うコマンド:
```bash
# すべてのローカル開発サービスを起動
npm run dev
# 個別のサービスを起動
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# サーバースタックをビルド
npm run build:server
# リポジトリ全体のチェック
npm run typecheck
```
## コミュニティ
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## ライセンス
AGPL-3.0
+1 -17
View File
@@ -6,8 +6,7 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
<a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
@@ -88,21 +87,6 @@ For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
### Docker
Run the Paseo daemon and self-hosted web UI in Docker:
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Open `http://localhost:6767` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/paseo` volume. See the [Docker documentation](docs/docker.md) for full setup details.
## CLI
Everything you can do in the app, you can do from the terminal.
+1 -2
View File
@@ -6,8 +6,7 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
<a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
-4
View File
@@ -50,10 +50,6 @@ Connected clients are trusted operators of the daemon user. File previews follow
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
`paseo` user by default. Mounted workspaces and credentials are still fully
available to anything the agents run inside the container.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
-17
View File
@@ -1,17 +0,0 @@
# Example child image that adds agent CLIs to the official Paseo image.
#
# Build:
# docker build -f docker/Dockerfile.agents.example -t paseo-with-agents .
#
# Then set `image: paseo-with-agents` in docker/docker-compose.example.yml.
FROM ghcr.io/getpaseo/paseo:latest
USER root
RUN npm install -g \
@anthropic-ai/claude-code \
@openai/codex \
opencode-ai
# Leave the image user as root. The base entrypoint prepares mounted volumes,
# then drops the daemon and launched agents to the non-root `paseo` user.
-30
View File
@@ -1,30 +0,0 @@
# Paseo Docker Image
This directory contains the official Paseo daemon image.
The image runs the daemon headless and serves the bundled web UI from the same
HTTP origin. Start it, then open the daemon URL in a browser.
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Then open `http://localhost:6767`.
The base image intentionally does not bundle agent CLIs. Extend it with the
agents you use:
```Dockerfile
FROM ghcr.io/getpaseo/paseo:latest
USER root
RUN npm install -g @openai/codex @anthropic-ai/claude-code
```
See [docs/docker.md](../docs/docker.md) for Compose, reverse proxy, security,
agent auth, and troubleshooting notes.
-80
View File
@@ -1,80 +0,0 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM ${NODE_IMAGE}
ARG PASEO_VERSION=latest
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
PASEO_LISTEN=0.0.0.0:6767 \
PASEO_WEB_UI_ENABLED=true \
PASEO_LOG_FORMAT=json \
PASEO_LOG_LEVEL=info \
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
CODEX_HOME=/home/paseo/.codex \
XDG_CONFIG_HOME=/home/paseo/.config \
XDG_DATA_HOME=/home/paseo/.local/share \
XDG_STATE_HOME=/home/paseo/.local/state \
XDG_CACHE_HOME=/home/paseo/.cache \
ONNXRUNTIME_NODE_INSTALL=skip
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
gosu \
lbzip2 \
openssh-client \
tini; \
rm -rf /var/lib/apt/lists/*
RUN set -eux; \
npm install -g --omit=optional \
"@getpaseo/server@${PASEO_VERSION}" \
"@getpaseo/cli@${PASEO_VERSION}"; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
node --check "$server_entry"
RUN set -eux; \
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
groupmod --new-name paseo "$existing_group"; \
elif [ -z "$existing_group" ]; then \
groupadd --gid 1000 paseo; \
fi; \
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
elif [ -z "$existing_user" ]; then \
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
fi; \
mkdir -p \
/workspace \
"$PASEO_HOME" \
"$CLAUDE_CONFIG_DIR" \
"$CODEX_HOME" \
"$XDG_CONFIG_HOME" \
"$XDG_DATA_HOME" \
"$XDG_STATE_HOME" \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
EXPOSE 6767
VOLUME ["/home/paseo"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
-101
View File
@@ -1,101 +0,0 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
ARG PASEO_VERSION
ENV ONNXRUNTIME_NODE_INSTALL=skip
WORKDIR /tmp/paseo-src
COPY . .
RUN set -eux; \
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
npm ci
RUN set -eux; \
mkdir -p /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
FROM ${NODE_IMAGE}
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
PASEO_LISTEN=0.0.0.0:6767 \
PASEO_WEB_UI_ENABLED=true \
PASEO_LOG_FORMAT=json \
PASEO_LOG_LEVEL=info \
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
CODEX_HOME=/home/paseo/.codex \
XDG_CONFIG_HOME=/home/paseo/.config \
XDG_DATA_HOME=/home/paseo/.local/share \
XDG_STATE_HOME=/home/paseo/.local/state \
XDG_CACHE_HOME=/home/paseo/.cache \
ONNXRUNTIME_NODE_INSTALL=skip
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
gosu \
lbzip2 \
openssh-client \
tini; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
RUN set -eux; \
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
node --check "$server_entry"
RUN set -eux; \
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
groupmod --new-name paseo "$existing_group"; \
elif [ -z "$existing_group" ]; then \
groupadd --gid 1000 paseo; \
fi; \
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
elif [ -z "$existing_user" ]; then \
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
fi; \
mkdir -p \
/workspace \
"$PASEO_HOME" \
"$CLAUDE_CONFIG_DIR" \
"$CODEX_HOME" \
"$XDG_CONFIG_HOME" \
"$XDG_DATA_HOME" \
"$XDG_STATE_HOME" \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY docker/base/rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
EXPOSE 6767
VOLUME ["/home/paseo"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_HOME="/home/paseo"
: "${HOME:=$IMAGE_HOME}"
: "${PASEO_HOME:=${HOME}/.paseo}"
: "${PASEO_LISTEN:=0.0.0.0:6767}"
: "${PASEO_WEB_UI_ENABLED:=true}"
: "${PASEO_LOG_LEVEL:=info}"
: "${PASEO_LOG_FORMAT:=json}"
: "${CLAUDE_CONFIG_DIR:=${HOME}/.claude}"
: "${CODEX_HOME:=${HOME}/.codex}"
: "${XDG_CONFIG_HOME:=${HOME}/.config}"
: "${XDG_DATA_HOME:=${HOME}/.local/share}"
: "${XDG_STATE_HOME:=${HOME}/.local/state}"
: "${XDG_CACHE_HOME:=${HOME}/.cache}"
export HOME
export PASEO_HOME
export PASEO_LISTEN
export PASEO_WEB_UI_ENABLED
export PASEO_LOG_LEVEL
export PASEO_LOG_FORMAT
export CLAUDE_CONFIG_DIR
export CODEX_HOME
export XDG_CONFIG_HOME
export XDG_DATA_HOME
export XDG_STATE_HOME
export XDG_CACHE_HOME
ensure_dir() {
local dir="$1"
mkdir -p "$dir"
if [[ "$(id -u)" == "0" ]]; then
local owner
owner="$(stat -c "%u" "$dir")"
if [[ "$owner" == "0" ]]; then
chown paseo:paseo "$dir"
fi
fi
}
ensure_dir "$HOME"
ensure_dir "$PASEO_HOME"
ensure_dir "$CLAUDE_CONFIG_DIR"
ensure_dir "$CODEX_HOME"
ensure_dir "$XDG_CONFIG_HOME"
ensure_dir "$XDG_DATA_HOME"
ensure_dir "$XDG_STATE_HOME"
ensure_dir "$XDG_CACHE_HOME"
if [[ "$#" -gt 0 ]]; then
if [[ "$(id -u)" == "0" ]]; then
exec gosu paseo "$@"
fi
exec "$@"
fi
if [[ -z "${PASEO_PASSWORD:-}" ]]; then
{
echo "[paseo] WARNING: PASEO_PASSWORD is not set."
echo "[paseo] The daemon accepts unauthenticated control connections from any client that can reach it."
echo "[paseo] Set PASEO_PASSWORD for any published port or network-reachable deployment."
} >&2
fi
if [[ ! -f /etc/paseo-server-entry ]]; then
echo "[paseo] FATAL: /etc/paseo-server-entry is missing." >&2
exit 1
fi
entry="$(cat /etc/paseo-server-entry)"
echo "[paseo] starting daemon on ${PASEO_LISTEN} with web UI ${PASEO_WEB_UI_ENABLED}"
if [[ "$(id -u)" == "0" ]]; then
exec gosu paseo node "$entry"
fi
exec node "$entry"
-21
View File
@@ -1,21 +0,0 @@
# Minimal Paseo daemon + web UI deployment.
#
# Open http://localhost:6767 after `docker compose up -d`.
# For any network-reachable deployment, change PASEO_PASSWORD first.
services:
paseo:
image: ghcr.io/getpaseo/paseo:latest
container_name: paseo
restart: unless-stopped
ports:
- "6767:6767"
environment:
PASEO_PASSWORD: "change-me"
# Add DNS names you use to reach this container. IPs and localhost are
# already allowed by default.
# PASEO_HOSTNAMES: "paseo.example.com,.lan"
volumes:
# Persistent daemon state and agent credentials/config.
- ./paseo-home:/home/paseo
# Code visible to Paseo and the agents it launches.
- ./workspace:/workspace
+11 -18
View File
@@ -14,23 +14,16 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
@@ -113,11 +106,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
+2 -6
View File
@@ -48,9 +48,8 @@ The heart of Paseo. A Node.js process that:
- Listens for WebSocket connections from clients
- Manages agent lifecycle (create, run, stop, resume, archive)
- Streams agent output in real time via a timeline model
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
- Exposes an MCP server for agent-to-agent control
- Optionally connects outbound to a relay for remote access
- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md))
All paths are under `packages/server/src/`.
@@ -63,8 +62,7 @@ All paths are under `packages/server/src/`.
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
@@ -291,8 +289,6 @@ All providers:
- Map tool calls to a normalized `ToolCallDetail` type
- Expose provider-specific modes (plan, default, full-access)
Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`.
## Data flow: running an agent
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
+1 -1
View File
@@ -438,7 +438,7 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{
+2 -37
View File
@@ -27,9 +27,6 @@ $PASEO_HOME/
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Expo push notification tokens
```
@@ -54,7 +51,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
@@ -150,7 +147,6 @@ Single file, validated with `PersistedConfigSchema`.
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
@@ -164,7 +160,7 @@ Single file, validated with `PersistedConfigSchema`.
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { voice: { apiKey: string, baseUrl: string } },
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
@@ -194,37 +190,6 @@ All fields are optional with sensible defaults.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI voice can be configured under `providers.openai`:
```json
{
"providers": {
"openai": {
"voice": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
---
## 3. Schedule
-82
View File
@@ -49,38 +49,6 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Expo Router layout ownership
Each layout owns only the routes directly inside its directory. In the root
layout, register `h/[serverId]`; do not register host leaf routes such as
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
routes with its own nested stack and relative screen names:
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
warns with `[Layout children]: No route named ...` when a layout registers
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
leave a nested index route mounted without its local dynamic params and render a
blank screen.
Do not paper over missing required route params by reading global params in the
leaf. Required dynamic params belong to the matched route. If
`useLocalSearchParams()` misses one, fix the layout ownership.
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
`.tsx` files there as routes, which produces `missing the required default
export` warnings and pollutes the route tree. Put shared route policy in
`src/navigation`, `src/utils`, or another non-route directory.
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
workspace for that host after the workspace-selection store hydrates unless the
host's hydrated workspace list proves that workspace is gone; hosts without a
remembered workspace go to `open-project`.
Keep workspace identity and retention outside native-stack `getId`/
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
@@ -236,56 +204,6 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
}
```
## Bundled daemon web UI
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it for a running daemon with:
```bash
paseo daemon start --web-ui
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon start
```
Or persist it in `config.json`:
```json
{
"features": {
"webUi": {
"enabled": true
}
}
}
```
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
Build the artifact for packaging or measurement with:
```bash
npm run build:daemon-web-ui
```
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
Measured bundle size for a standard Expo web export:
- raw: 10.77 MiB
- gzip: 2.55 MiB
- brotli: 1.93 MiB
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
## Built workspace packages
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
-223
View File
@@ -1,223 +0,0 @@
# Running Paseo in Docker
Paseo publishes a container image for running the daemon on a server, VM, NAS,
or homelab box. The image also serves the bundled browser web UI, so one
container gives you both the daemon API and a self-hosted UI.
The image source lives in [`docker/`](../docker/).
## How it works
The official image:
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
or from source-built workspace tarballs for beta images
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
- stores daemon state and agent credentials under `/home/paseo`
- leaves agent CLIs out of the base image
Open the container's HTTP origin, for example `http://localhost:6767`, to load
the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
## Quick Start
```bash
docker run -d --name paseo \
-p 6767:6767 \
-e PASEO_PASSWORD=change-me \
-v "$PWD/paseo-home:/home/paseo" \
-v "$PWD:/workspace" \
ghcr.io/getpaseo/paseo:latest
```
Then open:
```text
http://localhost:6767
```
If you set `PASEO_PASSWORD`, enter the same password when adding the direct
daemon connection in the web UI or another Paseo client.
## Docker Compose
Use [`docker/docker-compose.example.yml`](../docker/docker-compose.example.yml):
```bash
cp docker/docker-compose.example.yml docker-compose.yml
$EDITOR docker-compose.yml
docker compose up -d
```
Minimal example:
```yaml
services:
paseo:
image: ghcr.io/getpaseo/paseo:latest
restart: unless-stopped
ports:
- "6767:6767"
environment:
PASEO_PASSWORD: "change-me"
volumes:
- ./paseo-home:/home/paseo
- ./workspace:/workspace
```
## Installing Agents
The base image does not preinstall Claude Code, Codex, OpenCode, Copilot, Pi, or
other agent CLIs. That keeps the default image small and avoids coupling Paseo
releases to third-party agent release cycles.
Create a child image for the agents you use:
```Dockerfile
FROM ghcr.io/getpaseo/paseo:latest
USER root
RUN npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
```
Build it:
```bash
docker build -f Dockerfile -t paseo-with-agents .
```
Then use `image: paseo-with-agents` in Compose.
Leave the child image user as root. The base entrypoint uses root only for
first-run directory setup, then drops the daemon and launched agents to the
non-root `paseo` user.
An example child image is in
[`docker/Dockerfile.agents.example`](../docker/Dockerfile.agents.example).
You can also mount credentials from the host or run agent login once inside the
container:
```bash
docker exec -it --user paseo paseo codex
docker exec -it --user paseo paseo claude
```
Agent credentials and config persist in `/home/paseo`, alongside daemon state.
Provider environment variables such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
`OPENAI_BASE_URL`, or `ANTHROPIC_BASE_URL` can be passed through `docker run -e`
or `compose.environment`; Paseo passes them to launched agents.
## Volumes
| Mount | Purpose |
| ------------- | ------------------------------------------------------------------------ |
| `/home/paseo` | Paseo state under `.paseo` plus agent config such as `.codex`, `.claude` |
| `/workspace` | Code that Paseo and launched agents can read and write |
The image defaults:
| Variable | Default |
| -------------- | -------------------- |
| `HOME` | `/home/paseo` |
| `PASEO_HOME` | `/home/paseo/.paseo` |
| `PASEO_LISTEN` | `0.0.0.0:6767` |
If you bind-mount host directories on Linux, make sure the container user can
write them. The built-in `paseo` user has uid/gid `1000:1000`. For a different
host uid/gid, either adjust ownership on the mounted directories or run the
container with Docker's `--user` / Compose `user:` option.
## Reverse Proxies
When serving Paseo behind a reverse proxy, forward normal HTTP requests and
WebSocket upgrades to the same daemon port.
Caddy example:
```caddy
paseo.example.com {
reverse_proxy 127.0.0.1:6767
}
```
Nginx example:
```nginx
server {
listen 443 ssl;
server_name paseo.example.com;
location / {
proxy_pass http://127.0.0.1:6767;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
If you reach the daemon by DNS name, set `PASEO_HOSTNAMES` so host-header
validation allows that name:
```yaml
environment:
PASEO_HOSTNAMES: "paseo.example.com,.lan"
```
IPs and `localhost` are allowed by default.
## Security
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
- Prefer HTTPS at the reverse proxy for direct browser access.
- Use the Paseo relay for untrusted networks or mobile access when you do not
want to expose the daemon port directly.
- The container is the isolation boundary for agents. Agents can read and write
whatever you mount into `/workspace` and whatever credentials you place in
`/home/paseo`.
- The bundled web UI static files are public on the daemon origin. The daemon
API and WebSocket remain protected by password auth when configured.
See [SECURITY.md](../SECURITY.md) for the daemon trust model.
## Building Locally
```bash
docker build -t paseo:local docker/base
```
To bake a specific published npm version:
```bash
docker build \
--build-arg PASEO_VERSION=0.1.102 \
-t paseo:0.1.102 \
docker/base
```
The Docker workflow builds the image on pull requests and on `main` as a
non-publishing check. Stable `vX.Y.Z` tag pushes publish
`ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`. Beta tags
publish only the exact prerelease tag, such as
`ghcr.io/getpaseo/paseo:0.1.102-beta.1`, and do not update `latest`.
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
## Troubleshooting
- **The web UI loads but cannot connect**: if `PASEO_PASSWORD` is set, add a
direct connection with the same password.
- **403 Host not allowed**: set `PASEO_HOSTNAMES` to the DNS names you use.
- **Provider not available**: install that agent CLI in a child image or mount a
runtime where the binary is on `PATH`.
- **Permission errors in `/workspace`**: make the mounted directory writable by
uid/gid `1000:1000`, or run the container as the host uid/gid.
- **Logs**: inspect `docker logs paseo` or
`/home/paseo/.paseo/daemon.log` inside the container.
+2 -5
View File
@@ -8,12 +8,10 @@ Paseo client UI translations live in `packages/app/src/i18n`.
- `ar`
- `es`
- `fr`
- `ja`
- `pt-BR`
- `ru`
- `zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
## Translation Scope
@@ -35,7 +33,7 @@ Run:
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
```
The parity test catches missing keys across English and every supported locale resource.
The parity test catches missing keys between English and Simplified Chinese resources.
## Migration Order
@@ -79,4 +77,3 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
+9 -24
View File
@@ -10,16 +10,12 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
@@ -30,41 +26,31 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
## Provider Helper Processes
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
---
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
---
@@ -81,8 +67,6 @@ To add plan usage for a provider, add `packages/server/src/services/quota-fetche
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
---
## ACP Provider Checklist
@@ -344,13 +328,14 @@ interface AgentClient {
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listImportableSessions(
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listImportableSessions?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession(
importSession?(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
+1 -10
View File
@@ -13,15 +13,6 @@ Each domain becomes a controller class in its own file with the **exact** contra
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
## Progress (shipped — diverged from the original filenames)
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
@@ -126,7 +117,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing `packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
+1 -4
View File
@@ -27,7 +27,6 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Two paths
@@ -48,9 +47,7 @@ Before running any stable patch release command:
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
-14
View File
@@ -67,19 +67,6 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
```json
{
"version": 1,
"daemon": {
"trustedProxies": ["loopback", "172.16.0.0/12"]
}
}
```
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
Nginx example:
```nginx
@@ -90,7 +77,6 @@ server {
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
-1
View File
@@ -130,7 +130,6 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
## Agent authentication in tests
+1 -1
View File
@@ -1 +1 @@
sha256-Q8PQXknQerl0SOejZ+q/uCSqQwH541AHJ4bzu5Dap+A=
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
+48 -1441
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,12 +1,13 @@
{
"name": "paseo",
"version": "0.1.102-beta.1",
"version": "0.1.97",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
"development",
"mcp",
"openai",
"realtime",
"voice",
"voice-assistant"
],
@@ -55,7 +56,6 @@
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
+6 -4
View File
@@ -12,7 +12,7 @@ import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
@@ -21,13 +21,14 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
@@ -38,7 +39,8 @@ test.describe("Agent stream UI", () => {
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
-17
View File
@@ -26,26 +26,15 @@ import {
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
let workspaceId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
workspaceId = created.workspace.id;
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
@@ -53,12 +42,10 @@ test.describe("Archive tab reconciliation", () => {
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -94,12 +81,10 @@ test.describe("Archive tab reconciliation", () => {
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -126,12 +111,10 @@ test.describe("Archive tab reconciliation", () => {
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
+6 -51
View File
@@ -16,7 +16,6 @@ import {
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
dropFileOnComposer,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
@@ -37,11 +36,6 @@ const MINIMAL_PNG = Buffer.from(
);
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
const TEST_JSON = {
name: "config.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({ composer: "drop" })),
};
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
@@ -178,47 +172,6 @@ test.describe("Composer attachments", () => {
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("dropped JSON file renders as a file attachment in active chat", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "attach-drop-new-workspace-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
} finally {
await workspace.cleanup();
}
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
@@ -240,7 +193,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
@@ -252,7 +205,8 @@ test.describe("Composer attachments", () => {
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
@@ -260,7 +214,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
@@ -272,7 +226,8 @@ test.describe("Composer attachments", () => {
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
+4 -27
View File
@@ -228,7 +228,6 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await expectStoredCodeFontSize(page, 18);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
@@ -239,9 +238,6 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
@@ -274,13 +270,10 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
const actualFontSize = await page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
expect(actualFontSize).toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
@@ -407,22 +400,6 @@ async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number):
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
await expectStoredCodeFontSize(page, codeFontSize);
}
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(
(settingsKey) => localStorage.getItem(settingsKey),
APP_SETTINGS_KEY,
);
if (!raw) {
return null;
}
return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null;
})
.toBe(codeFontSize);
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {
@@ -4,7 +4,7 @@ import { gotoAppShell } from "./helpers/app";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -66,30 +66,6 @@ async function addProjectFromPicker(page: Page, projectPath: string): Promise<st
return testId!.replace("sidebar-project-row-", "");
}
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
await page
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout: 60_000 });
}
test.describe("Project picker search", () => {
test("shows a loading state after typing while directory suggestions are pending", async ({
page,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill("paseo-loading-state-no-match");
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
await expect(page.getByText("Searching...", { exact: true })).toBeVisible();
});
});
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
@@ -100,7 +76,7 @@ test.describe("Project with no workspaces persists", () => {
try {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await waitForNoProjectsInSidebar(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
@@ -116,7 +92,7 @@ test.describe("Project with no workspaces persists", () => {
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -187,7 +163,7 @@ test.describe("Project remove", () => {
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await waitForSidebarProjectListReady(page);
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readded = await workspace.client.addProject(workspace.repoPath);
@@ -203,6 +179,7 @@ test.describe("Project remove", () => {
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
await workspace.cleanup();
}
});
+44 -66
View File
@@ -11,8 +11,6 @@ import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
@@ -405,14 +403,10 @@ async function waitForPairingOfferFromDaemon(args: {
);
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
@@ -446,7 +440,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
}
}
async function logSpeechHarnessConfig(): Promise<void> {
async function resolveDictationConfig(): Promise<DictationConfig> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -457,20 +451,23 @@ async function logSpeechHarnessConfig(): Promise<void> {
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
// Default app E2E does not cover speech flows. Keep speech disabled here so
// unrelated tests never start background local-model downloads.
// Fork PRs run without secrets and usually without local models. Don't crash
// the whole Playwright run — disable dictation/voice and let tests that need
// them gate on PASEO_DICTATION_ENABLED.
if (!openAiUsable && !hasDefaultLocalModelsDir) {
console.warn(
"[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " +
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
);
return;
return { openAiUsable: false, localModelsDir: null };
}
const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`;
const dictationProvider = openAiUsable ? "openai" : "local";
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`,
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
@@ -575,18 +572,8 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
relayProcess = spawn(
process.execPath,
[
wranglerCliPath,
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
@@ -669,40 +656,39 @@ interface DaemonSpawnArgs {
paseoHome: string;
fakeEditorBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const env: NodeJS.ProcessEnv = {
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env,
env: {
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
@@ -734,15 +720,6 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
return child;
}
async function removeTempTree(targetPath: string): Promise<void> {
await rm(targetPath, {
recursive: true,
force: true,
maxRetries: 40,
retryDelay: 250,
});
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
@@ -754,13 +731,13 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await removeTempTree(paseoHome);
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeEditorBinDir) {
await removeTempTree(fakeEditorBinDir);
await rm(fakeEditorBinDir, { recursive: true, force: true });
fakeEditorBinDir = null;
}
}
@@ -784,7 +761,7 @@ export default async function globalSetup() {
const cleanup = () => performCleanup(shouldRemovePaseoHome);
await logSpeechHarnessConfig();
const dictation = await resolveDictationConfig();
try {
const relayPort = await startRelay(new Set([port, metroPort]));
@@ -800,6 +777,7 @@ export default async function globalSetup() {
paseoHome,
fakeEditorBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
});
+17 -6
View File
@@ -3,12 +3,11 @@ import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { getE2EDaemonPort } from "./daemon-port";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
buildSessionsRoute,
} from "@/utils/host-routes";
export interface ArchiveTabAgent {
@@ -36,6 +35,10 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
@@ -53,14 +56,20 @@ export interface IdleAgentSeedClient {
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: createdWorkspace.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -75,7 +84,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: createdWorkspace.workspace.id,
};
}
@@ -220,7 +229,9 @@ export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
+5 -48
View File
@@ -95,33 +95,6 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise<
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
export async function dropFileOnComposer(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const dataTransfer = await page.evaluateHandle(
({ name, mimeType, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const droppedFile = new File([bytes], name, { type: mimeType });
const transfer = new DataTransfer();
transfer.items.add(droppedFile);
return transfer;
},
{
name: file.name,
mimeType: file.mimeType,
base64: file.buffer.toString("base64"),
},
);
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
await composerRoot.dispatchEvent("dragover", { dataTransfer });
await composerRoot.dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
@@ -176,7 +149,6 @@ export async function selectGithubOption(
export interface MockAgentSetup {
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
cleanup: () => Promise<void>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
@@ -194,29 +166,20 @@ export async function startRunningMockAgent(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const workspace = createdWorkspace.workspace;
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: workspace.id,
workspaceId: createdWorkspace.workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
return {
client,
repo,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
};
return { client, repo };
}
export interface GithubWorkspaceHandle {
@@ -235,14 +198,8 @@ export async function openGithubWorkspace(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
const workspace = createdWorkspace.workspace;
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, workspace.id);
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
await waitForTabBar(page);
return {
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
return { cleanup: () => client.close().catch(() => undefined) };
}
+2 -3
View File
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "patchDaemonConfig"
| "removeProject"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -159,7 +158,7 @@ export async function openNewWorkspaceComposer(
await expect(button).toBeVisible({ timeout: 30_000 });
await button.click();
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
@@ -167,7 +166,7 @@ export async function openNewWorkspaceComposer(
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").click();
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
-3
View File
@@ -14,7 +14,6 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
projectId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
@@ -188,7 +187,6 @@ export async function launchAgent(input: {
page: input.page,
client,
agentId: agent.id,
projectId: createdWorkspace.workspace.projectId,
workspaceId: createdWorkspace.workspace.id,
cwd: input.cwd,
provider: input.provider,
@@ -198,7 +196,6 @@ export async function launchAgent(input: {
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.removeProject(handle.projectId).catch(() => undefined);
await handle.client.close().catch(() => undefined);
}
@@ -1,17 +0,0 @@
import { expect, type Page } from "@playwright/test";
export async function expectAppRoute(
page: Page,
expectedRoute: string,
options?: { timeout?: number },
): Promise<void> {
await expect
.poll(
() => {
const current = new URL(page.url());
return `${current.pathname}${current.search}`;
},
{ timeout: options?.timeout },
)
.toBe(expectedRoute);
}
+5 -8
View File
@@ -30,7 +30,6 @@ export interface SeedDaemonClient {
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -200,17 +199,15 @@ export async function seedWorkspace(options: {
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
return {
client,
repoPath: project.path,
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceId: created.workspace.id,
workspaceName: created.workspace.name,
workspaceDirectory: created.workspace.workspaceDirectory,
projectId: created.workspace.projectId,
projectDisplayName: created.workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},
+14 -17
View File
@@ -1,13 +1,7 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import {
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
@@ -39,12 +33,12 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expectAppRoute(page, buildProjectsSettingsRoute());
await expect(page).toHaveURL(/\/settings\/projects$/);
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expectAppRoute(page, buildSettingsSectionRoute(section));
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
@@ -61,7 +55,9 @@ export async function openSettingsHostSection(
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, section));
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -88,13 +84,13 @@ export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise<void> {
await expectAppRoute(page, expectedStartRoute, { timeout: 15_000 });
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expectAppRoute(page, buildSettingsRoute());
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
@@ -153,7 +149,7 @@ export async function expectSettingsHostPickerLabel(page: Page, label: string):
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expectAppRoute(page, buildSettingsRoute());
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
@@ -193,7 +189,7 @@ export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
await page.getByTestId("host-page-remove-host-button").click();
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
await page.getByTestId("remove-host-confirm").click();
await expectAppRoute(page, buildSettingsRoute());
await expect(page).toHaveURL(/\/settings$/);
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
@@ -205,7 +201,9 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "connections"));
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
);
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
@@ -251,7 +249,6 @@ export async function expectDirectHostUriHidden(page: Page): Promise<void> {
}
export async function expectDiagnosticsContent(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Run" })).toBeVisible();
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
}
+7 -8
View File
@@ -156,15 +156,14 @@ class StartupAssertions {
this.page = page;
}
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
async expectsSavedHostShell(input: { label: string }): Promise<this> {
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
await this.page.getByTestId("sidebar-hosts-trigger").click();
const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(hostRow).toBeVisible({ timeout: 15_000 });
await expect(hostRow).toContainText(input.label);
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
}
-5
View File
@@ -28,18 +28,15 @@ function sleep(ms: number): Promise<void> {
export class TerminalE2EHarness {
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
readonly projectId: string;
readonly workspaceId: string;
private constructor(input: {
client: SeedDaemonClient;
tempRepo: TempRepo;
projectId: string;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.projectId = input.projectId;
this.workspaceId = input.workspaceId;
}
@@ -57,13 +54,11 @@ export class TerminalE2EHarness {
return new TerminalE2EHarness({
client,
tempRepo,
projectId: seedResult.workspace.projectId,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.removeProject(this.projectId).catch(() => {});
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
@@ -37,7 +37,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let client: WorkspaceSetupDaemonClient | null = null;
const repos: Array<{ cleanup: () => Promise<void> }> = [];
const worktrees: WorktreeRecord[] = [];
const projectIds = new Set<string>();
const withWorkspace: WithWorkspace = async (options) => {
if (!client) {
@@ -65,7 +64,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
if (!added.project) {
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
}
projectIds.add(added.project.projectId);
}
const created = await client.createWorkspace({
@@ -75,7 +73,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
}
const workspaceId = created.workspace.id;
projectIds.add(created.workspace.projectId);
return {
workspaceId,
@@ -91,11 +88,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
return {
withWorkspace,
cleanup: async () => {
if (client) {
for (const projectId of projectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
for (const { repoPath, worktreePath } of worktrees) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
@@ -19,7 +19,6 @@ type WorkspaceSetupDaemonClient = Pick<
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "removeProject"
| "subscribeRawMessages"
>;
+4 -2
View File
@@ -1,7 +1,7 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
import { expectAppRoute } from "./route-assertions";
import { escapeRegex } from "./regex";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
@@ -59,7 +59,9 @@ export async function switchWorkspaceViaSidebar(input: {
await row.click();
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
await expectAppRoute(input.page, targetWorkspaceRoute, { timeout: 30_000 });
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000,
});
}
/**
+2 -15
View File
@@ -3,9 +3,6 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const TEMP_CLEANUP_RETRIES = 5;
const TEMP_CLEANUP_RETRY_DELAY_MS = 100;
interface TempRepo {
path: string;
branchHeads: Record<string, string>;
@@ -129,12 +126,7 @@ export const createTempGitRepo = async (
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, {
recursive: true,
force: true,
maxRetries: TEMP_CLEANUP_RETRIES,
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
});
await rm(repoPath, { recursive: true, force: true });
},
};
};
@@ -149,12 +141,7 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, {
recursive: true,
force: true,
maxRetries: TEMP_CLEANUP_RETRIES,
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
});
await rm(dirPath, { recursive: true, force: true });
},
};
}
@@ -1,220 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute } from "./helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { escapeRegex } from "./helpers/regex";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
mock: {
model: "ten-second-stream",
},
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
async function readCodexModePreference(page: Page): Promise<unknown> {
return page.evaluate((preferencesKey) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as {
providerPreferences?: Record<string, { mode?: unknown }>;
};
return parsed.providerPreferences?.codex?.mode ?? null;
}, CREATE_AGENT_PREFERENCES_KEY);
}
async function selectMode(page: Page, label: string): Promise<void> {
const modeControl = page.getByTestId("mode-control").first();
await expect(modeControl).toBeVisible({ timeout: 30_000 });
await modeControl.click();
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
const option = page
.getByRole("dialog")
.last()
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
.first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click({ force: true });
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
});
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
} finally {
await seeded.cleanup();
}
});
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
const agent = await seeded.client.createAgent({
provider: "codex",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Codex live mode preference e2e",
modeId: "auto",
model: "gpt-5.4-mini",
});
await openAgentRoute(page, {
workspaceId: seeded.workspaceId,
agentId: agent.id,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
} finally {
await seeded.cleanup();
}
});
});
+1 -48
View File
@@ -6,9 +6,7 @@ import {
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
} from "./helpers/new-workspace";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -40,19 +38,6 @@ test.describe("New workspace entry points", () => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" });
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: "secondary-new-workspace-host",
label: "Secondary host",
endpoint: "127.0.0.1:9",
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(
@@ -63,43 +48,11 @@ test.describe("New workspace entry points", () => {
await expect(globalButton).toBeVisible({ timeout: 30_000 });
await openGlobalNewWorkspaceComposer(page);
await expect(page.getByTestId("host-chooser")).toHaveCount(0);
// The screen is up: its project picker trigger is the canonical landmark.
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
} finally {
await seeded.cleanup();
}
});
test("the New Workspace screen hides the host selector when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
await openGlobalNewWorkspaceComposer(page);
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
} finally {
await seeded.cleanup();
}
+1 -1
View File
@@ -52,7 +52,7 @@ interface WorkspaceStatusGroupEvent {
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
+2 -61
View File
@@ -1,7 +1,7 @@
import { chmod, readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { expect, test as base } from "./fixtures";
import { seedWorkspace } from "./helpers/seed-client";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
@@ -30,8 +30,6 @@ import {
restorePaseoConfig,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
@@ -133,64 +131,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: path.basename(projectPath) })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}
async function openProjectSettingsFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const openSettingsItem = page.getByTestId(`sidebar-project-menu-open-settings-${projectId}`);
await expect(openSettingsItem).toBeVisible({ timeout: 10_000 });
await openSettingsItem.click();
}
test.describe("Projects settings", () => {
test("freshly-added project with no workspace is editable from the sidebar without a reload", async ({
page,
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectId: string | null = null;
try {
await gotoAppShell(page);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
+12 -18
View File
@@ -1,10 +1,5 @@
import { test, expect } from "./fixtures";
import {
buildHostWorkspaceRoute,
buildOpenProjectRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
@@ -41,7 +36,6 @@ import {
removeCurrentHostFromSettings,
} from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { expectAppRoute } from "./helpers/route-assertions";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -127,7 +121,7 @@ test.describe("Settings — compact master-detail", () => {
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettings(page);
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expectCompactSettingsList(page);
@@ -139,10 +133,10 @@ test.describe("Settings — compact master-detail", () => {
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettings(page);
await openSettingsSection(page, "diagnostics");
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
@@ -150,10 +144,10 @@ test.describe("Settings — compact master-detail", () => {
test("back from a section detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettings(page);
await openSettingsSection(page, "about");
await expectAppRoute(page, buildSettingsSectionRoute("about"));
await expect(page).toHaveURL(/\/settings\/about$/);
await goBackInSettings(page);
await expectCompactSettingsList(page);
@@ -164,7 +158,7 @@ test.describe("Settings — compact master-detail", () => {
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettings(page);
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
@@ -173,11 +167,11 @@ test.describe("Settings — compact master-detail", () => {
test("back from a host detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettings(page);
await openCompactSettingsHost(page);
await goBackInSettings(page);
await expectAppRoute(page, buildSettingsRoute());
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
});
@@ -194,11 +188,11 @@ test.describe("Settings — compact master-detail", () => {
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettings(page);
await selectSettingsHost(page, secondaryServerId);
await expectAppRoute(page, buildSettingsRoute());
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
@@ -212,7 +206,7 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
await openWorkspace(page, workspace);
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await openCompactSettings(page);
await openSettingsHostSection(page, getServerId(), "host");
await removeCurrentHostFromSettings(page);
await closeCompactSettings(page);
@@ -54,12 +54,10 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-b-${Date.now()}`,
});
@@ -97,12 +95,10 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-b-${Date.now()}`,
});
+1 -1
View File
@@ -127,7 +127,7 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -132,25 +132,6 @@ test.describe("Sidebar workspace list", () => {
await workspace.cleanup();
}
});
test("workspace hover card shows host as metadata", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
await row.hover();
const hoverCard = page.getByTestId("workspace-hover-card");
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
} finally {
await workspace.cleanup();
}
});
});
test.describe("Mobile sidebar panelState transition", () => {
+1 -1
View File
@@ -14,7 +14,7 @@ test.describe("Startup loading presentation", () => {
})
.openRoot();
await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" });
await startup.expectsSavedHostShell({ label: "Dev" });
await startup.expectsNoSavedHostErrorStatus();
await startup.expectsNoLocalServerStartupCopy();
});
@@ -34,7 +34,6 @@ test.describe("Workspace agent tab rename", () => {
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: initialTitle,
});
@@ -72,7 +72,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
@@ -390,7 +390,7 @@ async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
@@ -33,7 +33,6 @@ import {
import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -106,8 +105,12 @@ async function expectWorkspaceLocation(
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
const workspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId);
await expectAppRoute(page, workspaceRoute, { timeout: 30_000 });
await expect(page).toHaveURL(
buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId),
{
timeout: 30_000,
},
);
await expectWorkspaceHeader(page, {
title: input.workspace.workspaceName,
subtitle: input.workspace.projectDisplayName,
@@ -201,7 +204,6 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-reconnect-${Date.now()}`,
});
@@ -304,7 +306,6 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-refresh-route-${Date.now()}`,
});
await injectDesktopBridge(page, {
@@ -343,12 +344,10 @@ test.describe("Workspace navigation regression", () => {
try {
const firstAgent = await createIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: `workspace-nav-b-${Date.now()}`,
});
@@ -18,7 +18,6 @@ test.describe("Workspace pane mounting", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `pane-remount-${Date.now()}`,
});
@@ -20,7 +20,6 @@ test.describe("Worktree restore after daemon restart", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ retries: 0, timeout: 180_000 });
@@ -35,10 +34,6 @@ test.describe("Worktree restore after daemon restart", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -54,18 +49,15 @@ test.describe("Worktree restore after daemon restart", () => {
// the History table cells must show after restore — never "main".
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: worktreeSlug,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restart-restore-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+2 -13
View File
@@ -25,7 +25,6 @@ test.describe("Worktree restore", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
@@ -40,10 +39,6 @@ test.describe("Worktree restore", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -53,18 +48,15 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
@@ -98,18 +90,15 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.102-beta.1",
"version": "0.1.97",
"private": true,
"main": "index.ts",
"scripts": {
@@ -133,7 +133,7 @@
"serve-sim": "^0.1.40",
"typescript": "~5.9.2",
"vitest": "^4.1.6",
"wrangler": "^4.105.0",
"wrangler": "^4.75.0",
"ws": "^8.20.0"
}
}
+73 -114
View File
@@ -28,13 +28,11 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
@@ -42,7 +40,6 @@ import {
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import {
SidebarAnimationProvider,
useSidebarAnimation,
@@ -57,7 +54,7 @@ import {
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
} from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -80,7 +77,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHosts,
@@ -90,10 +86,11 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"
import { usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
buildHostRootRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
@@ -428,8 +425,12 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname();
const activeServerId = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
[daemons, pathname],
);
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
@@ -464,26 +465,14 @@ function AppContainer({
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{isCompactLayout && chromeEnabled ? (
<ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
</ExplorerSidebarAnimationProvider>
) : (
<View style={flexStyle}>{children}</View>
)}
</View>
);
const content = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
@@ -491,10 +480,12 @@ function AppContainer({
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
@@ -726,7 +717,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildOpenProjectRoute());
router.replace(buildHostRootRoute(serverId));
return;
})
.catch((error) => {
@@ -756,87 +747,48 @@ interface OpenProjectEventPayload {
path?: unknown;
}
interface PendingOpenProjectRequest {
id: number;
serverId: string;
path: string;
}
let nextOpenProjectRequestId = 1;
function OpenProjectListener() {
const chooseHost = useHostChooser();
const hostRegistryLoaded = useHostRegistryLoaded();
const [request, setRequest] = useState<PendingOpenProjectRequest | null>(null);
const [pendingPath, setPendingPath] = useState<string | null>(null);
const openProject = useOpenProject(request?.serverId ?? null);
const openPathOnChosenHost = useCallback(
(path: string) => {
const nextPath = path.trim();
if (!nextPath) {
return;
}
if (!hostRegistryLoaded) {
setPendingPath(nextPath);
return;
}
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
setRequest({
id: nextOpenProjectRequestId++,
serverId,
path: nextPath,
});
},
});
},
[chooseHost, hostRegistryLoaded],
);
useEffect(() => {
if (!hostRegistryLoaded || !pendingPath) {
return;
}
const nextPath = pendingPath;
setPendingPath(null);
openPathOnChosenHost(nextPath);
}, [hostRegistryLoaded, openPathOnChosenHost, pendingPath]);
useEffect(() => {
if (!request) {
return;
}
let cancelled = false;
void openProject(request.path).then((result) => {
if (cancelled) {
return null;
}
if (!result.ok) {
setRequest((current) => (current?.id === request.id ? null : current));
return null;
}
setRequest((current) => (current?.id === request.id ? null : current));
return null;
});
return () => {
cancelled = true;
};
}, [openProject, request]);
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
}
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
openPathOnChosenHost(pending);
maybeOpenProject(pending);
}
return;
})
@@ -848,7 +800,7 @@ function OpenProjectListener() {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
openPathOnChosenHost(nextPath);
maybeOpenProject(nextPath);
})
.then((dispose) => {
if (disposed) {
@@ -860,11 +812,13 @@ function OpenProjectListener() {
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [openPathOnChosenHost]);
}, [client, openProject, serverId]);
return null;
}
@@ -874,15 +828,9 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const routeHasKnownHost =
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome =
storeReady &&
(pathname === "/open-project" ||
pathname === "/new" ||
pathname === "/sessions" ||
routeHasKnownHost);
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -915,6 +863,8 @@ function FaviconStatusSync() {
return null;
}
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
@@ -937,12 +887,21 @@ function RootStack() {
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
{/*
Do not add getId or dangerouslySingular back to the workspace route.
Expo Router maps dangerouslySingular to React Navigation getId, and
getId repeatedly breaks Android native-stack/Fabric by reordering an
already-mounted workspace screen. Keep workspace identity/retention
outside this route-level native-stack API.
*/}
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>
+6 -26
View File
@@ -1,16 +1,8 @@
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteProvider } from "@/navigation/host-route-context";
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
const HOST_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
export default function HostRouteLayout() {
return <KnownHostRoute />;
}
@@ -32,20 +24,8 @@ function KnownHostRoute() {
return <Redirect href={startupRoute.href} />;
}
const stack = (
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
</Stack>
);
if (!routeServerId) {
return stack;
}
return <HostRouteProvider serverId={routeServerId}>{stack}</HostRouteProvider>;
// Keep the host Slot mounted while startup gates are active. React Navigation
// web can reserialize a shallower tree and drop nested workspace URL segments
// if the layout swaps Slot for a splash; leaf routes own the splash boundary.
return <Slot />;
}
+2 -3
View File
@@ -1,10 +1,9 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
if (!serverId) return null;
// COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildOpenProjectRoute()} />;
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
}
@@ -2,7 +2,7 @@ import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function NewWorkspaceRoute() {
export default function HostNewWorkspaceRoute() {
const params = useLocalSearchParams<{
serverId?: string;
dir?: string;
@@ -13,17 +13,10 @@ export default function NewWorkspaceRoute() {
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
const screenKey = JSON.stringify([
serverId,
sourceDirectory ?? null,
displayName ?? null,
projectId ?? null,
]);
return (
<HostRouteBootstrapBoundary>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
@@ -1,12 +1,18 @@
import { Redirect } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function HostOpenProjectRoute() {
// COMPAT(hostOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return (
<HostRouteBootstrapBoundary>
<Redirect href={buildOpenProjectRoute()} />
<HostOpenProjectRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <OpenProjectScreen serverId={serverId} />;
}
+16 -5
View File
@@ -1,7 +1,18 @@
import { Redirect } from "expo-router";
import { buildSessionsRoute } from "@/utils/host-routes";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function HostSessionsRoute() {
// COMPAT(hostSessionsRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildSessionsRoute()} />;
export default function HostAgentsRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentsRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <SessionsScreen serverId={serverId} />;
}
@@ -24,10 +24,6 @@ import {
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
@@ -67,7 +63,7 @@ function stripOpenSearchParamFromBrowserUrl() {
return;
}
url.searchParams.delete("open");
replaceBrowserRouteWithCanonicalHostWorkspaceRoute(`${url.pathname}${url.search}${url.hash}`);
window.history.replaceState(null, "", url.toString());
}
function clearConsumedOpenIntent(input: {
@@ -106,13 +102,6 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
}
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;
@@ -200,7 +200,6 @@ describe("resolveStartupRoute", () => {
hosts: [],
anyOnlineHostServerId: null,
workspaceSelection: null,
workspaceSelectionStatus: "unknown" as const,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
@@ -256,22 +255,10 @@ describe("resolveStartupRoute", () => {
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
});
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual({ kind: "redirect", href: "/h/server-1" });
});
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
expect(
resolveStartupRoute({
@@ -338,14 +325,14 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "render" });
});
it("sends removed host routes to global project selection instead of welcome", () => {
it("sends removed host routes to a saved host instead of welcome", () => {
expect(
resolveStartupRoute({
...baseHostInput,
route: { kind: "host", serverId: "server-removed" },
hosts: [{ serverId: "server-next" }],
}),
).toEqual({ kind: "redirect", href: "/open-project" });
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
});
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
@@ -2,9 +2,9 @@ import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-worksp
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
buildOpenProjectRoute,
} from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
@@ -136,7 +136,6 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
route: IndexStartupRouteTarget;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
@@ -152,28 +151,6 @@ export type StartupRouteDecision =
| { kind: "splash" }
| { kind: "redirect"; href: Href };
export type WorkspaceSelectionStatus = "unknown" | "exists" | "missing";
function shouldRestoreWorkspaceSelection(input: {
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): input is {
workspaceSelection: ActiveWorkspaceSelection;
workspaceSelectionStatus: Exclude<WorkspaceSelectionStatus, "missing">;
} {
return input.workspaceSelection !== null && input.workspaceSelectionStatus !== "missing";
}
export function resolveWorkspaceSelectionStatus(input: {
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
}): WorkspaceSelectionStatus {
if (input.workspaceExists) {
return "exists";
}
return input.hasHydratedWorkspaces ? "missing" : "unknown";
}
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
@@ -194,16 +171,11 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
return { kind: "splash" };
}
if (
shouldRestoreWorkspaceSelection(input) &&
hostExists(input.hosts, input.workspaceSelection.serverId)
) {
const workspaceSelection = input.workspaceSelection;
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
return {
kind: "redirect",
href: buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
),
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
};
}
@@ -230,7 +202,7 @@ function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): Star
const fallbackServerId = input.hosts[0]?.serverId ?? null;
if (fallbackServerId) {
return { kind: "redirect", href: buildOpenProjectRoute() };
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
}
return { kind: "redirect", href: WELCOME_ROUTE };
+1 -16
View File
@@ -2,12 +2,8 @@ import React from "react";
import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import {
resolveStartupRoute,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
@@ -24,13 +20,6 @@ export default function Index() {
const hostRegistryStatus = useHostRegistryStatus();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionServerId = workspaceSelection?.serverId ?? null;
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
const hasHydratedWorkspaceSelectionHost = useHasHydratedWorkspaces(workspaceSelectionServerId);
const workspaceSelectionExists = useWorkspaceExists(
workspaceSelectionServerId,
workspaceSelectionWorkspaceId,
);
const startupRoute = resolveStartupRoute({
route: { kind: "index", pathname },
@@ -39,10 +28,6 @@ export default function Index() {
hosts,
anyOnlineHostServerId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
-10
View File
@@ -1,10 +0,0 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function OpenProjectRoute() {
return (
<HostRouteBootstrapBoundary>
<OpenProjectScreen />
</HostRouteBootstrapBoundary>
);
}
-10
View File
@@ -1,10 +0,0 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function SessionsRoute() {
return (
<HostRouteBootstrapBoundary>
<SessionsScreen />
</HostRouteBootstrapBoundary>
);
}
+2 -3
View File
@@ -4,11 +4,10 @@ import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
return <SettingsScreen view={view} />;
}
@@ -10,7 +10,7 @@ import {
describe("attachment file types", () => {
it("keeps SVG as a file while treating raster image files as images", () => {
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("application/octet-stream");
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("image/svg+xml");
expect(isRasterImagePath("/tmp/logo.svg")).toBe(false);
expect(isRasterImageMimeType("image/svg+xml")).toBe(false);
expect(isRasterImageFile(new File(["<svg />"], "logo.svg", { type: "image/svg+xml" }))).toBe(
@@ -18,20 +18,11 @@ describe("attachment file types", () => {
);
expect(getRasterImageMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
expect(getMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
expect(isRasterImagePath("/tmp/screenshot.PNG?cache=1")).toBe(true);
expect(isRasterImageMimeType("image/png; charset=binary")).toBe(true);
expect(isRasterImageFile(new File([new Uint8Array([0])], "screenshot.png"))).toBe(true);
});
it("does not require MIME table entries for generic file attachments", () => {
expect(getMimeTypeFromPath("/tmp/notes.md")).toBe("application/octet-stream");
expect(getMimeTypeFromPath("/tmp/archive.zip")).toBe("application/octet-stream");
expect(getMimeTypeFromPath("/tmp/report.docx")).toBe("application/octet-stream");
expect(getMimeTypeFromPath("/tmp/runtime.log")).toBe("application/octet-stream");
expect(getMimeTypeFromPath("/tmp/export.anything")).toBe("application/octet-stream");
});
it("does not offer SVG in the image picker extension list", () => {
expect(new Set(RASTER_IMAGE_FILE_EXTENSIONS)).toEqual(
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
+43 -2
View File
@@ -1,3 +1,45 @@
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".json": "application/json",
".js": "text/javascript",
".ts": "text/typescript",
".tsx": "text/typescript-jsx",
".jsx": "text/javascript",
".html": "text/html",
".css": "text/css",
".xml": "text/xml",
".csv": "text/csv",
".zip": "application/zip",
".gz": "application/gzip",
".tar": "application/x-tar",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".wav": "audio/wav",
".ogg": "audio/ogg",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
};
const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -13,7 +55,6 @@ const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
};
const RASTER_IMAGE_MIME_TYPES = new Set(Object.values(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION));
const GENERIC_FILE_MIME_TYPE = "application/octet-stream";
export const RASTER_IMAGE_FILE_EXTENSIONS = Object.keys(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION).map(
(extension) => extension.slice(1),
@@ -34,7 +75,7 @@ export function getFileTypeLabel(path: string): string | null {
}
export function getMimeTypeFromPath(path: string): string {
return getRasterImageMimeTypeFromPath(path) ?? GENERIC_FILE_MIME_TYPE;
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
}
export function getRasterImageMimeTypeFromPath(path: string): string | null {
@@ -1,28 +0,0 @@
import { getFileExtension } from "@/attachments/file-types";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
export interface PickedFile {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: path,
extension: getFileExtension(path) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
return base64ToUint8Array(base64);
}
+21 -101
View File
@@ -33,7 +33,6 @@ interface AgentListProps {
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
showHostColumn?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
@@ -153,62 +152,11 @@ function WorkspaceTitlePrefix({
);
}
function SessionRowBadges({
agent,
archivedIcon,
pendingPermissionCount,
showDesktopAttention,
}: {
agent: AggregatedAgent;
archivedIcon: ReactElement;
pendingPermissionCount: number;
showDesktopAttention: boolean;
}) {
const { t } = useTranslation();
return (
<>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{showDesktopAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</>
);
}
function SessionRowTrailingAttention({
isMobile,
showAttentionIndicator,
requiresAttention,
}: {
isMobile: boolean;
showAttentionIndicator: boolean;
requiresAttention: boolean | undefined;
}) {
const { t } = useTranslation();
if (!isMobile || !showAttentionIndicator || !requiresAttention) {
return null;
}
return (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
);
}
function SessionRow({
agent,
isMobile,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
onPress,
onLongPress,
}: {
@@ -216,7 +164,6 @@ function SessionRow({
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
showHostColumn: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
@@ -253,8 +200,6 @@ function SessionRow({
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
[theme.fontSize.xs, theme.colors.foregroundMuted],
);
const showDesktopAttention =
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
return (
<Pressable
@@ -278,14 +223,20 @@ function SessionRow({
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
pendingPermissionCount={pendingPermissionCount}
showDesktopAttention={showDesktopAttention}
/>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</View>
{isMobile ? (
{isMobile && (
<View style={styles.rowMetaRow}>
<Text
style={styles.sessionMetaText}
@@ -312,18 +263,10 @@ function SessionRow({
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
) : null}
)}
</View>
{!isMobile ? (
{!isMobile && (
<View style={styles.rowColumns}>
<Text
style={styles.columnMeta}
@@ -332,11 +275,6 @@ function SessionRow({
>
{projectName}
</Text>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
style={styles.columnMeta}
numberOfLines={1}
@@ -348,12 +286,12 @@ function SessionRow({
{timeAgo}
</Text>
</View>
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
<SessionRowTrailingAttention
isMobile={isMobile}
showAttentionIndicator={showAttentionIndicator}
requiresAttention={agent.requiresAttention}
/>
</Pressable>
);
}
@@ -366,7 +304,6 @@ export function AgentList({
onAgentSelect,
listFooterComponent,
showAttentionIndicator = true,
showHostColumn = false,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -491,21 +428,12 @@ export function AgentList({
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
showHostColumn={showHostColumn}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
);
},
[
handleAgentLongPress,
handleAgentPress,
isMobile,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
t,
],
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -712,14 +640,6 @@ const styles = StyleSheet.create((theme) => ({
width: 72,
textAlign: "right" as const,
},
columnMetaHost: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 120,
marginLeft: theme.spacing[4],
textAlign: "right" as const,
},
badge: {
flexDirection: "row",
alignItems: "center",
@@ -1,412 +0,0 @@
import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Copy, RotateCw } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import {
formatAppDiagnosticHeader,
formatDiagnosticSection,
formatHostRuntimeSection,
formatServerInfoSection,
redactAppDiagnosticReport,
} from "@/diagnostics/app-diagnostic-report";
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
interface AppDiagnosticSheetProps {
visible: boolean;
onClose: () => void;
appVersion: string | null;
isDesktopApp: boolean;
}
type ProgressStatus = "pending" | "running" | "done" | "failed";
interface ProgressRow {
id: string;
label: string;
status: ProgressStatus;
}
interface DiagnosticCollectionResult {
sections: string[];
status: ProgressStatus;
}
const SNAP_POINTS = ["55%", "88%"];
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function AppDiagnosticSheet({
visible,
onClose,
appVersion,
isDesktopApp,
}: AppDiagnosticSheetProps) {
const { t } = useTranslation();
const toast = useToast();
const hosts = useHosts();
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState<ProgressRow[]>([]);
const runIdRef = useRef(0);
const updateProgress = useCallback((id: string, label: string, status: ProgressStatus) => {
setProgress((current) => {
const existing = current.find((row) => row.id === id);
if (existing) {
return current.map((row) => (row.id === id ? { ...row, label, status } : row));
}
return [...current, { id, label, status }];
});
}, []);
const runDiagnostics = useCallback(async () => {
const runId = runIdRef.current + 1;
runIdRef.current = runId;
const isCurrentRun = () => runIdRef.current === runId;
const updateRunProgress = (id: string, label: string, status: ProgressStatus) => {
if (isCurrentRun()) {
updateProgress(id, label, status);
}
};
setLoading(true);
setDiagnostic(null);
setProgress([]);
const sections: string[] = [];
try {
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "running");
sections.push(
formatAppDiagnosticHeader({
appVersion,
platform: Platform.OS,
isDesktopApp,
hostCount: hosts.length,
}),
);
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "done");
if (isDesktopApp) {
const desktopLabel = t("settings.diagnostics.app.progress.desktop");
updateRunProgress("desktop", desktopLabel, "running");
const desktopResult = await collectDesktopDiagnosticSections();
sections.push(...desktopResult.sections);
updateRunProgress("desktop", desktopLabel, desktopResult.status);
}
const store = getHostRuntimeStore();
for (const host of hosts) {
const hostProgressId = `host:${host.serverId}`;
updateRunProgress(hostProgressId, host.label, "running");
const snapshot = store.getSnapshot(host.serverId);
const hostResult = await collectHostDiagnosticSections(host, snapshot);
sections.push(...hostResult.sections);
updateRunProgress(hostProgressId, host.label, hostResult.status);
}
if (isCurrentRun()) {
setDiagnostic(redactAppDiagnosticReport(sections.join("\n\n"), hosts));
}
} finally {
if (isCurrentRun()) {
setLoading(false);
}
}
}, [appVersion, hosts, isDesktopApp, t, updateProgress]);
useEffect(() => {
if (visible) {
void runDiagnostics();
} else {
runIdRef.current += 1;
setLoading(false);
setDiagnostic(null);
setProgress([]);
}
}, [visible, runDiagnostics]);
const handleRefreshPress = useCallback(() => {
void runDiagnostics();
}, [runDiagnostics]);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.diagnostics.app.copyLabel")))
.catch(() => toast.error(t("settings.diagnostics.app.copyFailed")));
}, [diagnostic, t, toast]);
const iconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && styles.iconButtonHovered,
],
[],
);
const disabledIconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered,
diagnostic ? null : styles.disabled,
],
[diagnostic],
);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.diagnostics.app.title"),
actions: (
<View style={styles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={disabledIconButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
>
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.diagnostics.app.refreshingAccessibility")
: t("settings.diagnostics.app.refreshAccessibility")
}
>
{loading ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
</View>
),
}),
[
diagnostic,
disabledIconButtonStyle,
handleCopyPress,
handleRefreshPress,
iconButtonStyle,
loading,
t,
],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}
scrollable={false}
testID="app-diagnostic-sheet"
>
{diagnostic ? (
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={520}>
{diagnostic}
</ScrollableCodeSurface>
) : (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={styles.progressContent}>
{progress.length === 0 ? (
<View style={styles.progressRow}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
<Text style={styles.mutedText}>{t("settings.diagnostics.app.running")}</Text>
</View>
) : (
progress.map((row) => (
<View key={row.id} style={styles.progressRow}>
{row.status === "running" || row.status === "pending" ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedColorMapping}
/>
) : (
<View
style={row.status === "failed" ? styles.statusDotFailed : styles.statusDot}
/>
)}
<Text
style={styles.mutedText}
>{`${row.label}: ${formatProgressStatus(row.status)}`}</Text>
</View>
))
)}
</View>
</SurfaceCard>
)}
</AdaptiveModalSheet>
);
}
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
try {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return {
status: "done",
sections: [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: logs.logPath || "unknown" },
{ label: "Error", value: status.error ?? "none" },
]),
[
"Desktop daemon log tail",
logs.contents ? indentBlock(logs.contents) : " No log lines found",
].join("\n"),
],
};
} catch (error) {
return {
status: "failed",
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
};
}
}
async function collectHostDiagnosticSections(
host: HostProfile,
snapshot: HostRuntimeSnapshot | null,
): Promise<DiagnosticCollectionResult> {
const sections = [formatHostRuntimeSection({ host, snapshot })];
const client = snapshot?.client ?? null;
if (snapshot?.connectionStatus !== "online" || !client) {
sections.push(
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
{ label: "Status", value: "host is not connected" },
]),
);
return { sections, status: "done" };
}
try {
const serverInfo = client.getLastServerInfoMessage();
sections.push(formatServerInfoSection(serverInfo));
const rttMs = await client.measureLatency({ timeoutMs: 5000 });
sections.push(
formatDiagnosticSection(`Host latency: ${host.label}`, [
{ label: "Active RTT", value: `${Math.round(rttMs)}ms` },
]),
);
if (serverInfo?.features?.daemonDiagnostics === true) {
const result = await client.collectDiagnostics();
sections.push(result.diagnostic);
} else {
sections.push(
formatDiagnosticSection(`Daemon diagnostics: ${host.label}`, [
{ label: "Status", value: "unsupported by this daemon" },
]),
);
}
return { sections, status: "done" };
} catch (error) {
sections.push(
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
{ label: "Error", value: toMessage(error) },
]),
);
return { sections, status: "failed" };
}
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function formatProgressStatus(status: ProgressStatus): string {
switch (status) {
case "done":
return "done";
case "failed":
return "failed";
case "running":
case "pending":
return "running";
}
}
const styles = StyleSheet.create((theme) => ({
progressContent: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[3],
},
progressRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 24,
},
mutedText: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
iconButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.success,
},
statusDotFailed: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.destructive,
},
}));
@@ -1,106 +0,0 @@
import { describe, expect, it } from "vitest";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
} from "@/components/compact-explorer-sidebar-host-state";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function createModel(
overrides: Partial<CompactExplorerSidebarHostModel> = {},
): CompactExplorerSidebarHostModel {
return {
serverId: overrides.serverId ?? "server-1",
workspaceId: overrides.workspaceId ?? "workspace-a",
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
isGit: overrides.isGit ?? true,
};
}
describe("resolveCompactExplorerSidebarHostModel", () => {
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
const previous = createModel();
const result = resolveCompactExplorerSidebarHostModel({
previous,
selection: { serverId: "server-1", workspaceId: "workspace-a" },
workspace: null,
isGit: false,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
persistenceKey: "server-1:workspace-a",
workspaceRoot: "/repo/a",
isGit: true,
});
});
it("switches ownership to the active workspace instead of leaking the previous one", () => {
const previous = createModel();
const result = resolveCompactExplorerSidebarHostModel({
previous,
selection: { serverId: "server-1", workspaceId: "workspace-b" },
workspace: null,
isGit: false,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
persistenceKey: "server-1:workspace-b",
workspaceRoot: "",
isGit: false,
});
});
it("does not retain a previous owner when there is no active workspace selection", () => {
const result = resolveCompactExplorerSidebarHostModel({
previous: createModel(),
selection: null,
workspace: null,
isGit: false,
});
expect(result).toBeNull();
});
it("uses the current workspace directory when it is available", () => {
const result = resolveCompactExplorerSidebarHostModel({
previous: null,
selection: { serverId: "server-1", workspaceId: "workspace-a" },
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
isGit: true,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
persistenceKey: "server-1:workspace-a",
workspaceRoot: "/repo/current",
isGit: true,
});
});
});
@@ -1,59 +0,0 @@
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export interface CompactExplorerSidebarHostModel {
serverId: string;
workspaceId: string;
persistenceKey: string;
workspaceRoot: string;
isGit: boolean;
}
interface ResolveCompactExplorerSidebarHostModelInput {
previous: CompactExplorerSidebarHostModel | null;
selection: ActiveWorkspaceSelection | null;
workspace: WorkspaceDescriptor | null;
isGit: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveCompactExplorerSidebarHostModel(
input: ResolveCompactExplorerSidebarHostModelInput,
): CompactExplorerSidebarHostModel | null {
const serverId = trimNonEmpty(input.selection?.serverId);
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!persistenceKey) {
return null;
}
const previousForSelection =
input.previous &&
input.previous.serverId === serverId &&
input.previous.workspaceId === workspaceId
? input.previous
: null;
return {
serverId,
workspaceId,
persistenceKey,
workspaceRoot:
trimNonEmpty(input.workspace?.workspaceDirectory) ??
previousForSelection?.workspaceRoot ??
"",
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
};
}
@@ -1,162 +0,0 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
} from "@/components/compact-explorer-sidebar-host-state";
interface CompactExplorerOpenGestureSurfaceProps {
children: ReactNode;
enabled: boolean;
onOpenExplorer: () => void;
}
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function CompactExplorerOpenGestureSurface({
children,
enabled,
onOpenExplorer,
}: CompactExplorerOpenGestureSurfaceProps) {
const explorerOpenGesture = useExplorerOpenGesture({
enabled,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.fill}>{children}</View>
</GestureDetector>
);
}
function useActiveCompactExplorerSidebarModel(
enabled: boolean,
): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: true }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused: enabled && selection !== null,
normalizedServerId: selection?.serverId ?? "",
normalizedWorkspaceId: selection?.workspaceId ?? "",
workspaceDirectory: workspace?.workspaceDirectory || null,
});
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
}),
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
);
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerOpen) {
showMobileAgent();
}
return;
}
if (!isExplorerOpen) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) {
retainedModelRef.current = resolvedModel;
}
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
}
interface CompactExplorerSidebarHostProps {
children: ReactNode;
enabled: boolean;
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
return;
}
openFileExplorerForCheckout({
isCompact: true,
checkout: {
serverId: model.serverId,
cwd: model.workspaceRoot,
isGit: model.isGit,
},
});
}, [model, openFileExplorerForCheckout]);
const handleOpenFile = useCallback(
(filePath: string) => {
if (!model) {
return;
}
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
showMobileAgent,
openWorkspaceTabFocused,
focusWorkspaceTab,
});
},
[focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent],
);
return (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</>
);
}
const styles = {
fill: {
flex: 1,
},
} as const;
@@ -9,25 +9,23 @@ import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
maxTokens: number | null;
usedTokens: number | null;
maxTokens: number;
usedTokens: number;
totalCostUsd?: number | null;
showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const SVG_SIZE = 16;
const COMPACT_SVG_SIZE = 14;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const RADIUS = 7;
const COMPACT_RADIUS = 6;
const STROKE_WIDTH = 2.25;
const COMPACT_STROKE_WIDTH = 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
@@ -102,7 +100,6 @@ export function ContextWindowMeter({
showPercentage = false,
serverId,
provider,
pending = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -111,8 +108,7 @@ export function ContextWindowMeter({
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const percentage = getUsagePercentage(maxTokens, usedTokens);
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
@@ -123,42 +119,14 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
getMeterGeometry(showPercentage);
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
@@ -262,14 +230,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: {
gap: theme.spacing[1.5],
gap: theme.spacing[1],
minWidth: 200,
},
tooltipTitle: {
+122 -178
View File
@@ -7,6 +7,7 @@ import {
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -15,13 +16,9 @@ import { useTranslation } from "react-i18next";
import {
formatPrTabLabel,
PullRequestPane,
PullRequestPaneError,
PullRequestPaneSkeleton,
PullRequestTabIcon,
usePrPaneData,
} from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import {
usePanelStore,
selectIsFileExplorerOpen,
@@ -31,9 +28,8 @@ import {
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -53,29 +49,7 @@ interface ExplorerSidebarProps {
onOpenFile?: (filePath: string) => void;
}
interface ExplorerSidebarSharedState {
explorerTab: ExplorerTab;
handleTabPress: (tab: ExplorerTab) => void;
}
function useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
const explorerTab = usePanelStore((state) => state.explorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
return { explorerTab, handleTabPress };
}
export function CompactExplorerSidebar({
export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
@@ -83,22 +57,40 @@ export function CompactExplorerSidebar({
onOpenFile,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: true,
enabled: isMobile,
});
useEffect(() => {
if (isMobile) {
return;
}
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
}
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
const {
translateX,
backdropOpacity,
@@ -111,15 +103,23 @@ export function CompactExplorerSidebar({
closeGestureRef,
} = useExplorerSidebarAnimation();
// For resize drag, track the starting width
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
const handleClose = useCallback(
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen,
});
showMobileAgent();
if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
},
[isOpen, showMobileAgent],
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
);
const handleCloseFromGesture = useCallback(() => {
@@ -128,14 +128,24 @@ export function CompactExplorerSidebar({
showMobileAgent();
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
const enableSidebarCloseGesture = isMobile;
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(true)
.enabled(enableSidebarCloseGesture)
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
@@ -209,6 +219,7 @@ export function CompactExplorerSidebar({
isGesturing.value = false;
}),
[
enableSidebarCloseGesture,
windowWidth,
translateX,
backdropOpacity,
@@ -223,6 +234,32 @@ export function CompactExplorerSidebar({
],
);
// Desktop resize gesture (drag left edge)
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(!isMobile)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = explorerWidth;
resizeWidth.value = explorerWidth;
})
.onUpdate((event) => {
// Dragging left (negative translationX) increases width
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
@@ -231,6 +268,10 @@ export function CompactExplorerSidebar({
opacity: backdropOpacity.value,
}));
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const backdropCombinedStyle = useMemo(
() => [
explorerStaticStyles.backdrop,
@@ -271,6 +312,10 @@ export function CompactExplorerSidebar({
],
[overlayVisible],
);
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
@@ -279,101 +324,39 @@ export function CompactExplorerSidebar({
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropCombinedStyle} />
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
if (!isScreenFocused) {
return null;
}
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
);
}
if (isMobile) {
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={backdropCombinedStyle} />
export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
}: ExplorerSidebarProps) {
const insets = useSafeAreaInsets();
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const { width: viewportWidth } = useWindowDimensions();
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
useEffect(() => {
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={isMobile}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
}
}, [explorerWidth, setExplorerWidth, viewportWidth]);
const handleDesktopClose = useCallback(() => {
logExplorerSidebar("handleClose", {
reason: "desktop-close-button",
isOpen,
});
closeDesktopFileExplorer();
}, [closeDesktopFileExplorer, isOpen]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = explorerWidth;
resizeWidth.value = explorerWidth;
})
.onUpdate((event) => {
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
}
// Desktop: fixed width sidebar with resize handle
if (!isOpen) {
return null;
}
@@ -381,11 +364,12 @@ export function ExplorerSidebar({
return (
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View style={RESIZE_HANDLE_STYLE} />
</GestureDetector>
<ExplorerSidebarContent
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleDesktopClose}
@@ -443,7 +427,7 @@ interface SidebarContentProps {
onOpenFile?: (filePath: string) => void;
}
function ExplorerSidebarContent({
function SidebarContent({
activeTab,
onTabPress,
onClose,
@@ -457,7 +441,6 @@ function ExplorerSidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
@@ -467,17 +450,11 @@ function ExplorerSidebarContent({
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
});
const hasPullRequest = prPane.prNumber !== null;
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
const resolvedTab: ExplorerTab =
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot],
@@ -510,7 +487,7 @@ function ExplorerSidebarContent({
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
{isGit && showPrTab && (
{isGit && hasPullRequest && (
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
@@ -554,13 +531,12 @@ function ExplorerSidebarContent({
onOpenFile={onOpenFile}
/>
)}
{resolvedTab === "pr" && (
<PrTabContent
{resolvedTab === "pr" && prPane.data && (
<PullRequestPane
serverId={serverId}
cwd={workspaceRoot}
prPane={prPane}
data={prPane.data}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/>
)}
</View>
@@ -568,38 +544,6 @@ function ExplorerSidebarContent({
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
onRetry: () => void;
}
function PrTabContent({
serverId,
cwd,
prPane,
workspaceAttachmentScopeKey,
onRetry,
}: PrTabContentProps) {
if (prPane.data) {
return (
<PullRequestPane
serverId={serverId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
/>
);
}
if (prPane.error) {
return <PullRequestPaneError onRetry={onRetry} />;
}
return <PullRequestPaneSkeleton />;
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
@@ -1,5 +1,4 @@
import { View, Text } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react";
@@ -14,7 +13,6 @@ interface FileDropZoneProps {
onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
}
const IS_WEB = isWeb;
@@ -24,7 +22,6 @@ export function FileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled = false,
style,
}: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
@@ -49,7 +46,6 @@ export function FileDropZone({
() => [styles.overlay, overlayAnimatedStyle],
[overlayAnimatedStyle],
);
const containerStyle = useMemo(() => [styles.container, style], [style]);
// On non-web platforms, just render children
if (!IS_WEB) {
@@ -60,7 +56,7 @@ export function FileDropZone({
<View
// Cast ref for web - View renders as div on web
ref={containerRef as unknown as React.RefObject<View>}
style={containerStyle}
style={styles.container}
>
{children}
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { isFileQueryEnabled } from "./file-pane-enabled";
describe("isFileQueryEnabled", () => {
it("reads when there is a target, the tab is active, and the app is visible", () => {
expect(isFileQueryEnabled({ hasReadTarget: true, isTabActive: true, isAppVisible: true })).toBe(
true,
);
});
it("does not read while the tab is hidden", () => {
expect(
isFileQueryEnabled({ hasReadTarget: true, isTabActive: false, isAppVisible: true }),
).toBe(false);
});
it("does not read while the app is backgrounded", () => {
expect(
isFileQueryEnabled({ hasReadTarget: true, isTabActive: true, isAppVisible: false }),
).toBe(false);
});
it("does not read without a resolved file target", () => {
expect(
isFileQueryEnabled({ hasReadTarget: false, isTabActive: true, isAppVisible: true }),
).toBe(false);
});
});
@@ -1,17 +0,0 @@
/**
* Whether `FilePane` should read its file right now.
*
* The read is gated on visibility so a revisited tab refetches instead of showing
* the frozen first-load snapshot (#445): React Query refetches on the
* disabled→enabled transition (stale-gated by the query's staleTime). The file is
* read only when there is something to read AND the pane can actually show it —
* the tab is the active one (not a hidden, mounted-but-offscreen tab) and the
* whole app is in the foreground.
*/
export function isFileQueryEnabled(input: {
hasReadTarget: boolean;
isTabActive: boolean;
isAppVisible: boolean;
}): boolean {
return input.hasReadTarget && input.isTabActive && input.isAppVisible;
}
+2 -15
View File
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useMemo, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import {
@@ -29,9 +29,6 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { MountedTabActiveContext } from "@/components/split-container";
import { useAppVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -411,19 +408,9 @@ export function FilePane({
[normalizedFilePath, normalizedWorkspaceRoot],
);
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
// gate itself lives in isFileQueryEnabled.
const isActive = useContext(MountedTabActiveContext);
const isAppVisible = useAppVisible();
const query = useQuery({
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
enabled: isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
}),
enabled: Boolean(client && readTarget),
queryFn: async () => {
if (!client || !readTarget) {
return {
@@ -29,8 +29,6 @@ const LANGUAGE_ALIASES: Record<string, string> = {
rust: "rs",
golang: "go",
"c++": "cpp",
csharp: "cs",
"c#": "cs",
objc: "m",
"objective-c": "m",
markdown: "md",
@@ -1,31 +0,0 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type HostRuntimeConnectionStatus,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
if (status === "online") return theme.colors.palette.green[400];
if (status === "connecting") return theme.colors.palette.amber[500];
return theme.colors.palette.red[500];
}
export function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const status = useHostRuntimeConnectionStatus(serverId);
const backgroundColor = hostStatusDotColor(status, theme);
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
return <View style={dotStyle} />;
}
const styles = StyleSheet.create(() => ({
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
}));
@@ -1,19 +0,0 @@
export const ADD_HOST_OPTION_ID = "__add_host__";
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
export function getHostPickerLabel(
hosts: Array<{ label: string; serverId: string }>,
value: string,
config?: { includeAllHost?: boolean; includeAddHost?: boolean },
): string {
if (config?.includeAllHost && value === ALL_HOSTS_OPTION_ID) {
return "All hosts";
}
if (config?.includeAddHost && value === ADD_HOST_OPTION_ID) {
return "Add host";
}
return (
hosts.find((host) => host.serverId === value)?.label ??
(config?.includeAllHost ? "All hosts" : "Host")
);
}
@@ -1,19 +0,0 @@
import { expect, it } from "vitest";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
const hosts = [{ serverId: "host-a", label: "Host A" }];
it.each([
["host-a", undefined, "Host A"],
["missing", undefined, "Host"],
[ALL_HOSTS_OPTION_ID, { includeAllHost: true }, "All hosts"],
["missing", { includeAllHost: true }, "All hosts"],
[ALL_HOSTS_OPTION_ID, undefined, "Host"],
[ADD_HOST_OPTION_ID, { includeAddHost: true }, "Add host"],
])("resolves %s", (value, config, expected) => {
expect(getHostPickerLabel(hosts, value, config)).toBe(expected);
});
@@ -1,300 +0,0 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Plus, Server, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDot } from "@/components/host-status-dot";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
interface HostPickerHost {
serverId: string;
label: string;
}
export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElement {
return (
<View style={styles.statusDotSlot}>
<HostStatusDot serverId={serverId} />
</View>
);
}
export interface HostPickerOptionProps {
serverId: string;
label: string;
isLocal: boolean;
selected?: boolean;
active: boolean;
onPress: () => void;
onOpenHostSettings?: (serverId: string) => void;
localMarkerTestID?: string;
testID?: string;
}
export function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
onOpenHostSettings,
localMarkerTestID,
testID,
}: HostPickerOptionProps): ReactElement {
const { theme } = useUnistyles();
const leadingSlot = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
const handleSettingsPress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onOpenHostSettings?.(serverId);
},
[onOpenHostSettings, serverId],
);
const trailingSlot = useMemo(() => {
if (!isLocal && !onOpenHostSettings) return undefined;
return (
<>
{isLocal ? (
<Text style={styles.localMarker} testID={localMarkerTestID}>
Local
</Text>
) : null}
{onOpenHostSettings ? (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</>
);
}, [
handleSettingsPress,
isLocal,
label,
localMarkerTestID,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
);
}
function SystemHostPickerOption({
active,
selected,
onPress,
kind,
testID,
}: {
active: boolean;
selected?: boolean;
onPress: () => void;
kind: "add" | "all";
testID?: string;
}): ReactElement {
const { theme } = useUnistyles();
const Icon = kind === "add" ? Plus : Server;
const label = kind === "add" ? "Add host" : "All hosts";
const leadingSlot = useMemo(
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
);
}
export interface HostPickerProps {
hosts: HostPickerHost[];
value: string;
onSelect: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
anchorRef: React.RefObject<View | null>;
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
showLocalMarker?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
desktopPlacement?: "top-start" | "bottom-start";
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
hostLocalMarkerTestID?: (serverId: string) => string;
children: ReactNode;
}
export function HostPicker({
hosts,
value,
onSelect,
open,
onOpenChange,
anchorRef,
includeAllHost,
includeAddHost,
onAddHost,
showLocalMarker,
onOpenHostSettings,
searchable,
title,
desktopPlacement = "top-start",
desktopMinWidth,
addHostTestID,
hostOptionTestID,
hostLocalMarkerTestID,
children,
}: HostPickerProps): ReactElement {
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
const options = useMemo(() => {
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
return hostOptions;
}, [orderedHosts, includeAllHost, includeAddHost]);
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost?.();
} else {
onSelect(id);
}
onOpenChange(false);
},
[onAddHost, onOpenChange, onSelect],
);
const handleOpenHostSettings = useCallback(
(serverId: string) => {
onOpenHostSettings?.(serverId);
onOpenChange(false);
},
[onOpenHostSettings, onOpenChange],
);
const renderOption = useCallback<RenderHostOption>(
({ option, selected, active, onPress }) => {
if (option.id === ADD_HOST_OPTION_ID) {
return (
<SystemHostPickerOption
kind="add"
active={active}
onPress={onPress}
testID={addHostTestID}
/>
);
}
if (option.id === ALL_HOSTS_OPTION_ID) {
return (
<SystemHostPickerOption
kind="all"
active={active}
selected={selected}
onPress={onPress}
/>
);
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={showLocalMarker === true && localServerId === option.id}
selected={selected}
active={active}
onPress={onPress}
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
testID={hostOptionTestID?.(option.id)}
/>
);
},
[
addHostTestID,
hostLocalMarkerTestID,
hostOptionTestID,
localServerId,
onOpenHostSettings,
showLocalMarker,
handleOpenHostSettings,
],
);
return (
<>
{children}
<Combobox
options={options}
value={value}
onSelect={handleSelect}
renderOption={renderOption}
searchable={isSearchable}
searchPlaceholder="Search hosts"
title={title ?? "Host"}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
/>
</>
);
}
const styles = StyleSheet.create((theme) => ({
statusDotSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
},
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
}));
@@ -1,14 +0,0 @@
import Svg, { Path } from "react-native-svg";
interface MiniMaxIconProps {
size?: number;
color?: string;
}
export function MiniMaxIcon({ size = 16, color = "currentColor" }: MiniMaxIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color} fillRule="evenodd">
<Path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" />
</Svg>
);
}
+357 -160
View File
@@ -1,7 +1,18 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
import { FolderPlus, History, Home, Plus, Search, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
type Dispatch,
memo,
type ReactElement,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
@@ -9,6 +20,8 @@ import {
useWindowDimensions,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -21,9 +34,9 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -34,28 +47,27 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
useSidebarWorkspacesList,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
usePanelStore,
} from "@/stores/panel-store";
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
buildHostOpenProjectRoute,
buildHostNewWorkspaceRoute,
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -73,9 +85,14 @@ interface LeftSidebarProps {
interface SidebarSharedProps {
theme: SidebarTheme;
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
@@ -84,14 +101,19 @@ interface SidebarSharedProps {
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
newWorkspaceKeys: ShortcutKey[][] | null;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
@@ -132,24 +154,70 @@ export const LeftSidebar = memo(function LeftSidebar({
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname();
const daemons = useHosts();
const activeDaemon = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname }),
[daemons, pathname],
);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return t("sidebar.host.noHost");
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon, t]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting")
: "idle";
let activeHostStatusColor: string;
if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400];
else if (activeHostStatus === "connecting")
activeHostStatusColor = theme.colors.palette.amber[500];
else activeHostStatusColor = theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
})),
[daemons],
);
const renderHostOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<HostSwitchOption
serverId={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
/>
),
[],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const {
workspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarWorkspacesList({
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isCompactLayout || isOpen,
});
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects });
useSidebarShortcutModel({ projects, isInitialLoad });
const groupMode = useSidebarViewStore((state) => state.groupMode);
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
);
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -164,7 +232,7 @@ export const LeftSidebar = memo(function LeftSidebar({
}
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker();
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
@@ -176,8 +244,9 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
router.push(buildNewWorkspaceRoute());
}, []);
if (!activeServerId) return;
router.navigate(buildHostNewWorkspaceRoute(activeServerId));
}, [activeServerId]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
@@ -188,41 +257,38 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
showMobileAgent();
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
},
[showMobileAgent],
);
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
}, []);
const handleHomeMobile = useCallback(() => {
if (!activeServerId) return;
showMobileAgent();
router.push(buildOpenProjectRoute());
}, [showMobileAgent]);
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId, showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
router.push(buildOpenProjectRoute());
}, []);
if (!activeServerId) return;
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
router.push(buildSessionsRoute());
}, []);
if (!activeServerId) {
return;
}
router.push(buildHostSessionsRoute(activeServerId));
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath);
},
[pathname],
);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
@@ -239,9 +305,14 @@ export const LeftSidebar = memo(function LeftSidebar({
const sharedProps = {
theme,
statusWorkspacePlacements,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -250,6 +321,8 @@ export const LeftSidebar = memo(function LeftSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
labels,
newWorkspaceKeys,
};
@@ -266,8 +339,6 @@ export const LeftSidebar = memo(function LeftSidebar({
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
);
@@ -282,41 +353,91 @@ export const LeftSidebar = memo(function LeftSidebar({
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
);
});
function sidebarHostOptionTestID(serverId: string): string {
return `sidebar-host-row-${serverId}`;
interface HostPickerTriggerProps {
triggerRef: React.Ref<View>;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
hostOptionsEmpty: boolean;
hostStatusDotStyle: StyleProp<ViewStyle>;
activeHostLabel: string;
}
function sidebarHostLocalMarkerTestID(serverId: string): string {
return `sidebar-host-local-marker-${serverId}`;
function HostPickerTrigger({
triggerRef,
setIsHostPickerOpen,
hostOptionsEmpty,
hostStatusDotStyle,
activeHostLabel,
}: HostPickerTriggerProps) {
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
],
[],
);
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
return (
<Pressable
ref={triggerRef}
style={pressableStyle}
onPress={handlePress}
disabled={hostOptionsEmpty}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
);
}
function HostSwitchOption({
serverId,
label,
selected,
active,
onPress,
}: {
serverId: string;
label: string;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const snapshot = useHostRuntimeSnapshot(serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
return (
<ComboboxItem
label={label}
description={formatConnectionStatus(connectionStatus)}
selected={selected}
active={active}
onPress={onPress}
/>
);
}
function FooterIconButton({
buttonRef,
onPress,
testID,
accessibilityLabel,
icon: Icon,
iconSize,
theme,
}: {
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof FolderPlus;
iconSize?: number;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
return (
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
@@ -328,7 +449,7 @@ function FooterIconButton({
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -336,59 +457,6 @@ function FooterIconButton({
);
}
function SidebarHostPicker({
theme,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}) {
const hosts = useHosts();
const triggerRef = useRef<View | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
onOpenHostSettings(id);
},
[onOpenHostSettings],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
return (
<HostPicker
hosts={hosts}
value=""
onSelect={handleSelect}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
onOpenHostSettings={onOpenHostSettings}
searchable
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
>
<FooterIconButton
buttonRef={triggerRef}
onPress={handleOpen}
testID="sidebar-hosts-trigger"
accessibilityLabel="Hosts"
icon={Server}
iconSize={theme.iconSize.sm}
theme={theme}
/>
</HostPicker>
);
}
function AddProjectTooltipContent({
newAgentKeys,
label,
@@ -421,14 +489,30 @@ function HeaderIconTooltipContent({
function SidebarFooter({
theme,
activeServerId,
activeHostLabel,
hostStatusDotStyle,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
}: {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
hostStatusDotStyle: StyleProp<ViewStyle>;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
@@ -439,19 +523,20 @@ function SidebarFooter({
switchHost: string;
searchHosts: string;
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
return (
<View style={styles.sidebarFooter}>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
<View style={styles.footerHostSlot}>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FooterIconButton
@@ -481,15 +566,33 @@ function SidebarFooter({
theme={theme}
/>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title={labels.switchHost}
searchPlaceholder={labels.searchHosts}
desktopMinWidth={280}
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
);
}
function MobileSidebar({
theme,
statusWorkspacePlacements,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -498,14 +601,14 @@ function MobileSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
insetsBottom,
isOpen,
@@ -535,11 +638,21 @@ function MobileSidebar({
}, [closeSidebar, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return;
}
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewMoreNavigate();
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
}, [
activeServerId,
backdropOpacity,
closeSidebar,
handleViewMoreNavigate,
translateX,
windowWidth,
]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
@@ -639,6 +752,11 @@ function MobileSidebar({
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
@@ -708,7 +826,7 @@ function MobileSidebar({
variant="compact"
/>
</View>
<WorkspacesSectionHeader />
<WorkspacesSectionHeader serverId={activeServerId} />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -733,13 +851,12 @@ function MobileSidebar({
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
@@ -750,12 +867,19 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</Animated.View>
@@ -766,9 +890,14 @@ function MobileSidebar({
function DesktopSidebar({
theme,
statusWorkspacePlacements,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -777,14 +906,14 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
isOpen,
handleViewMore,
@@ -795,6 +924,10 @@ function DesktopSidebar({
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor],
);
const startWidthRef = useRef(sidebarWidth);
const resizeWidth = useSharedValue(sidebarWidth);
@@ -874,19 +1007,18 @@ function DesktopSidebar({
/>
</View>
</View>
<WorkspacesSectionHeader />
<WorkspacesSectionHeader serverId={activeServerId} />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
@@ -897,12 +1029,19 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
{/* Resize handle - absolutely positioned over right border */}
@@ -914,7 +1053,7 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader() {
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
@@ -957,7 +1096,7 @@ function WorkspacesSectionHeader() {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
<SidebarGroupingSelector serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -1069,15 +1208,45 @@ const styles = StyleSheet.create((theme) => ({
sidebarDragArea: {
position: "relative",
},
sidebarFooter: {
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
hostTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerHostSlot: {
flexGrow: 0,
flexShrink: 1,
minWidth: 0,
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
@@ -1092,6 +1261,34 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
hostPickerList: {
gap: theme.spacing[2],
},
hostPickerOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
hostPickerOptionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostPickerCancel: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",

Some files were not shown because too many files have changed in this diff Show More