Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 98c0033f22 test(daemon): make self-update paths platform-stable 2026-06-26 13:25:17 +07:00
Mohamed Boudra 444214cbe2 feat(daemon): harden remote self-update
Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.
2026-06-26 13:02:35 +07:00
Davy 7f6403f517 fix: use npm install@latest and global concurrency guard
Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard
2026-06-26 12:54:17 +07:00
Davy be8c2a54ce fix: clean up progress subscription on unmount, use typed phase cast
Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }
2026-06-26 12:53:23 +07:00
Davy c6c030ab26 feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
2026-06-26 12:53:23 +07:00
141 changed files with 835 additions and 5000 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
-33
View File
@@ -1,38 +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
-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.
-15
View File
@@ -88,21 +88,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.
-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
-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.
+3 -3
View File
@@ -56,15 +56,15 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## 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.
---
+1 -3
View File
@@ -48,9 +48,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).
+1 -1
View File
@@ -1 +1 @@
sha256-Q8PQXknQerl0SOejZ+q/uCSqQwH541AHJ4bzu5Dap+A=
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=
+23 -1390
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -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-" });
@@ -73,23 +73,6 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
.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", () => {
+2 -14
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;
@@ -575,18 +573,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 },
-27
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,
+2 -2
View File
@@ -159,7 +159,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 +167,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,
});
}
+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();
}
+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);
@@ -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", () => {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"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"
}
}
+1 -6
View File
@@ -878,11 +878,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const routeHasKnownHost =
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
const shouldShowAppChrome =
storeReady &&
(pathname === "/open-project" ||
pathname === "/new" ||
pathname === "/sessions" ||
routeHasKnownHost);
storeReady && (pathname === "/open-project" || pathname === "/sessions" || routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -937,7 +933,6 @@ 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" />
@@ -39,6 +39,7 @@ function KnownHostRoute() {
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="new" />
<Stack.Screen name="settings" />
</Stack>
);
@@ -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}
@@ -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);
}
@@ -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",
@@ -100,6 +100,7 @@ export function HostPickerOption({
trailingSlot={trailingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
@@ -133,6 +134,7 @@ function SystemHostPickerOption({
leadingSlot={leadingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
@@ -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>
);
}
+10 -3
View File
@@ -30,6 +30,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
@@ -51,7 +52,7 @@ import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildHostNewWorkspaceRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
@@ -165,6 +166,7 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
@@ -176,8 +178,13 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
router.push(buildNewWorkspaceRoute());
}, []);
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildHostNewWorkspaceRoute(serverId));
},
});
}, [chooseHost]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
@@ -7,8 +7,6 @@ import {
TextInput,
View,
type PressableStateCallbackType,
type StyleProp,
type TextStyle,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -70,68 +68,6 @@ function PathRow({ option, active, onSelect }: PathRowProps) {
);
}
interface ProjectPickerResultsProps {
options: ProjectPickerOption[];
activeIndex: number;
isSubmitting: boolean;
openErrorMessage: string | null;
hasQuery: boolean;
isSearching: boolean;
emptyTextStyle: StyleProp<TextStyle>;
errorTextStyle: StyleProp<TextStyle>;
onSelect: (path: string) => void;
}
function ProjectPickerResults({
options,
activeIndex,
isSubmitting,
openErrorMessage,
hasQuery,
isSearching,
emptyTextStyle,
errorTextStyle,
onSelect,
}: ProjectPickerResultsProps) {
const { t } = useTranslation();
const canShowResultState = !isSubmitting && !openErrorMessage;
return (
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{canShowResultState && options.length === 0 && !hasQuery ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{canShowResultState && isSearching ? (
<Text style={emptyTextStyle}>{t("projectPicker.searching")}</Text>
) : null}
{canShowResultState && !isSearching && options.length === 0 && hasQuery ? (
<Text style={emptyTextStyle}>{t("common.empty.noOptionsMatchSearch")}</Text>
) : null}
{canShowResultState && options.length > 0 ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
onSelect={onSelect}
/>
))}
</>
) : null}
</ScrollView>
);
}
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -146,18 +82,17 @@ export function ProjectPickerModal() {
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(null);
const openProject = useOpenProject(serverId);
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
queryKey: ["project-picker-directory-suggestions", serverId, query],
queryFn: async () => {
if (!client) return [];
const result = await client.getDirectorySuggestions({
query: debouncedQuery,
query,
includeDirectories: true,
includeFiles: false,
limit: 30,
@@ -180,11 +115,6 @@ export function ProjectPickerModal() {
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const hasQuery = query.trim().length > 0;
const isSearching =
hasQuery &&
options.length === 0 &&
(query !== debouncedQuery || directorySuggestionsQuery.isFetching);
const openErrorMessage = useMemo(() => {
if (!openErrorReason) {
@@ -235,7 +165,6 @@ export function ProjectPickerModal() {
useEffect(() => {
if (open) {
setQuery("");
setDebouncedQuery("");
setActiveIndex(0);
setOpenErrorReason(null);
const id = setTimeout(() => inputRef.current?.focus(), 0);
@@ -243,13 +172,6 @@ export function ProjectPickerModal() {
}
}, [open]);
// Debounce the query that drives the (potentially multi-second) directory
// suggestions RPC so fast typing doesn't fire a filesystem scan per keystroke.
useEffect(() => {
const id = setTimeout(() => setDebouncedQuery(query), 250);
return () => clearTimeout(id);
}, [query]);
useEffect(() => {
if (!open) return;
if (activeIndex >= options.length) {
@@ -345,17 +267,32 @@ export function ProjectPickerModal() {
/>
</View>
<ProjectPickerResults
options={options}
activeIndex={activeIndex}
isSubmitting={isSubmitting}
openErrorMessage={openErrorMessage}
hasQuery={hasQuery}
isSearching={isSearching}
emptyTextStyle={emptyTextStyle}
errorTextStyle={errorTextStyle}
onSelect={handleSelectPath}
/>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
onSelect={handleSelectPath}
/>
))}
</>
) : null}
</ScrollView>
</View>
</View>
</Modal>
@@ -12,7 +12,6 @@ describe("resolveProviderIconName", () => {
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
@@ -4,7 +4,6 @@ import { SvgXml } from "react-native-svg";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PiIcon } from "@/components/icons/pi-icon";
@@ -23,7 +22,6 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
@@ -59,7 +59,7 @@ import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
buildNewWorkspaceRoute,
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
@@ -992,9 +992,7 @@ function NewWorkspaceGhostRow({
const handlePress = useCallback(() => {
onWorkspacePress?.();
router.navigate(
buildNewWorkspaceRoute({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
buildHostNewWorkspaceRoute(worktreeTarget.serverId, worktreeTarget.iconWorkingDir, {
displayName,
projectId: project.projectKey,
}) as Href,
@@ -1289,9 +1287,7 @@ function ProjectHeaderRow({
}
onWorkspacePress?.();
router.navigate(
buildNewWorkspaceRoute({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
buildHostNewWorkspaceRoute(worktreeTarget.serverId, worktreeTarget.iconWorkingDir, {
displayName,
projectId: project.projectKey,
}) as Href,
@@ -2735,7 +2731,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
@@ -826,7 +826,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
@@ -252,7 +252,6 @@ export default function TerminalEmulator({
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const lastMetricsRef = useRef({ offset: 0, viewportSize: 0, contentSize: 0 });
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme;
@@ -291,19 +290,6 @@ export default function TerminalEmulator({
const [isScrollActive, setIsScrollActive] = useState(false);
const [isDropActive, setIsDropActive] = useState(false);
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateViewportMetricsState = useCallback((metrics: ViewportMetrics) => {
const lastMetrics = lastMetricsRef.current;
if (
metrics.offset === lastMetrics.offset &&
metrics.viewportSize === lastMetrics.viewportSize &&
metrics.contentSize === lastMetrics.contentSize
) {
return;
}
lastMetricsRef.current = metrics;
setViewportMetrics(metrics);
}, []);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
useDOMImperativeHandle(
@@ -589,30 +575,24 @@ export default function TerminalEmulator({
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
if (!viewportElement) {
viewportRef.current = null;
updateViewportMetricsState({ offset: 0, viewportSize: 0, contentSize: 0 });
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
return () => {};
}
viewportRef.current = viewportElement;
const updateViewportMetrics = () => {
const offset = Math.max(0, viewportElement.scrollTop);
const viewportSize = Math.max(0, viewportElement.clientHeight);
const contentSize = Math.max(0, viewportElement.scrollHeight);
updateViewportMetricsState({ offset, viewportSize, contentSize });
setViewportMetrics({
offset: Math.max(0, viewportElement.scrollTop),
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
});
};
updateViewportMetrics();
let scrollRafId: number | null = null;
const handleViewportScroll = () => {
if (scrollRafId !== null) {
return;
}
scrollRafId = requestAnimationFrame(() => {
scrollRafId = null;
updateViewportMetrics();
});
updateViewportMetrics();
};
const resizeObserver = new ResizeObserver(() => {
@@ -624,20 +604,27 @@ export default function TerminalEmulator({
resizeObserver.observe(scrollAreaElement);
}
const mutationObserver = new MutationObserver(() => {
updateViewportMetrics();
});
mutationObserver.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
return () => {
if (scrollRafId !== null) {
cancelAnimationFrame(scrollRafId);
scrollRafId = null;
}
viewportElement.removeEventListener("scroll", handleViewportScroll);
resizeObserver.disconnect();
mutationObserver.disconnect();
if (viewportRef.current === viewportElement) {
viewportRef.current = null;
}
};
}, [streamKey, updateViewportMetricsState]);
}, [streamKey]);
useEffect(() => {
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
@@ -713,7 +700,7 @@ export default function TerminalEmulator({
return;
}
viewportElement.scrollTop = nextOffset;
updateViewportMetricsState({
setViewportMetrics({
offset: nextOffset,
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
@@ -733,12 +720,7 @@ export default function TerminalEmulator({
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
updateViewportMetricsState,
]);
}, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]);
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
@@ -61,15 +61,11 @@ export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function C
const styles = StyleSheet.create((theme) => ({
row: {
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
chevronContainer: {
flexShrink: 0,
transform: [{ translateY: 1 }],
},
}));
+10 -4
View File
@@ -230,6 +230,7 @@ export interface ComboboxItemProps {
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
interactiveFeedback?: boolean;
onPress: () => void;
testID?: string;
}
@@ -244,6 +245,7 @@ export function ComboboxItem({
active,
disabled,
elevated,
interactiveFeedback = true,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -269,12 +271,16 @@ export function ComboboxItem({
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
interactiveFeedback &&
hovered &&
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
interactiveFeedback &&
pressed &&
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
interactiveFeedback && active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
],
[elevated, active, disabled],
[elevated, active, disabled, interactiveFeedback],
);
const itemContentStyle = useMemo(
@@ -21,7 +21,6 @@ import {
ExternalLink,
Folder,
GitBranch,
Server,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
@@ -40,7 +39,8 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHosts } from "@/runtime/host-runtime";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
interface Rect {
x: number;
@@ -330,16 +330,20 @@ function WorkspaceHoverCardContent({
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedFolder = withUnistyles(Folder);
const ThemedServer = withUnistyles(Server);
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
const hosts = useHosts();
const host = hosts.find((h) => h.serverId === serverId);
const snapshot = useHostRuntimeSnapshot(serverId);
const label = host?.label?.trim() || serverId;
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
return <InfoRow icon={ThemedServer} value={label} testID="hover-card-workspace-host" />;
return (
<View style={styles.hostRow}>
<Text style={styles.hostRowLabel}>{label}</Text>
<Text style={styles.hostRowStatus}>{status}</Text>
</View>
);
}
const ThemedExternalLink = withUnistyles(ExternalLink);
@@ -356,25 +360,6 @@ const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSucce
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
function InfoRow({
icon: Icon,
value,
testID,
}: {
icon: CardInfoIcon;
value: string;
testID: string;
}) {
return (
<View style={styles.cardInfoRow}>
<Icon size={12} uniProps={foregroundMutedColorMapping} />
<Text style={styles.cardInfoText} numberOfLines={1} testID={testID}>
{value}
</Text>
</View>
);
}
function CopyableInfoRow({
icon: Icon,
value,
@@ -382,7 +367,7 @@ function CopyableInfoRow({
copyLabel,
testID,
}: {
icon: CardInfoIcon;
icon: React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
value: string;
copyValue: string;
copyLabel: string;
@@ -582,6 +567,22 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
hostRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hostRowLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hostRowStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
cardMetaRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -26,7 +26,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { MessagePayload } from "@/composer/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -363,6 +363,14 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo(
@@ -419,6 +427,7 @@ export function WorkspaceSetupDialog() {
snapPoints={SNAP_POINTS}
testID="workspace-setup-dialog"
desktopMaxWidth={640}
onFilesDropped={handleFilesDropped}
>
<View style={styles.section}>
<Composer
@@ -438,6 +447,7 @@ export function WorkspaceSetupDialog() {
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
onAddImages={handleAddImagesCallback}
footer={composerFooter}
/>
</View>
@@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
import { droppedItemsToPickedFiles } from "./drop";
describe("composer dropped attachments", () => {
it("turns non-image browser files into picked files and leaves raster images for image handling", async () => {
const jsonFile = new File([JSON.stringify({ ok: true })], "config.json", {
type: "application/json",
});
const imageFile = new File([new Uint8Array([0])], "screen.png", { type: "image/png" });
const files = await droppedItemsToPickedFiles([
{ kind: "web-file", file: jsonFile },
{ kind: "web-file", file: imageFile },
]);
expect(files).toEqual([
{
fileName: "config.json",
mimeType: "application/json",
bytes: new Uint8Array(await jsonFile.arrayBuffer()),
},
]);
});
it("turns non-image desktop paths into picked files and leaves raster images for image handling", async () => {
const windowsPath = "C:\\Users\\alice\\config.json";
const posixPath = "/Users/alice/notes/readme.txt";
const imagePath = "C:\\Users\\alice\\screen.png";
const bytesByPath = new Map([
[windowsPath, new Uint8Array([1, 2, 3])],
[posixPath, new Uint8Array([4, 5])],
]);
const readPaths: string[] = [];
const files = await droppedItemsToPickedFiles(
[
{ kind: "desktop-path", path: windowsPath },
{ kind: "desktop-path", path: imagePath },
{ kind: "desktop-path", path: posixPath },
],
{
readDesktopFileBytes: async (path) => {
readPaths.push(path);
const bytes = bytesByPath.get(path);
if (!bytes) {
throw new Error(`Unexpected desktop read: ${path}`);
}
return bytes;
},
},
);
expect(readPaths).toEqual([windowsPath, posixPath]);
expect(files).toEqual([
{
fileName: "config.json",
mimeType: "application/octet-stream",
bytes: new Uint8Array([1, 2, 3]),
},
{
fileName: "readme.txt",
mimeType: "application/octet-stream",
bytes: new Uint8Array([4, 5]),
},
]);
});
});
@@ -1,58 +0,0 @@
import {
getMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
interface DroppedAttachmentsRuntime {
readDesktopFileBytes(path: string): Promise<Uint8Array>;
}
const defaultRuntime: DroppedAttachmentsRuntime = {
readDesktopFileBytes,
};
function fileNameFromPath(path: string): string {
const segments = path.split(/[/\\]/);
for (let index = segments.length - 1; index >= 0; index -= 1) {
const segment = segments[index];
if (segment) {
return segment;
}
}
return path;
}
export async function droppedItemsToPickedFiles(
items: DroppedItem[],
runtime: DroppedAttachmentsRuntime = defaultRuntime,
): Promise<PickedFile[]> {
const files: PickedFile[] = [];
for (const item of items) {
if (item.kind === "web-file") {
if (isRasterImageFile(item.file)) {
continue;
}
files.push({
fileName: item.file.name,
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
bytes: new Uint8Array(await item.file.arrayBuffer()),
});
continue;
}
if (isRasterImagePath(item.path)) {
continue;
}
files.push({
fileName: fileNameFromPath(item.path),
mimeType: getMimeTypeFromPath(item.path),
bytes: await runtime.readDesktopFileBytes(item.path),
});
}
return files;
}
@@ -10,8 +10,10 @@ import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import type { ImageAttachment } from "@/composer/types";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
@@ -330,6 +332,7 @@ export function WorkspaceDraftAgentTab({
initialSetup: draftSetup,
});
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo(
() =>
buildDraftStoreKey({
@@ -537,6 +540,14 @@ export function WorkspaceDraftAgentTab({
workspaceId,
]);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const focusInputRef = useRef<(() => void) | null>(null);
const handleFocusInputCallback = useCallback((focus: () => void) => {
@@ -644,65 +655,71 @@ export function WorkspaceDraftAgentTab({
);
return (
<View style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</View>
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</View>
</ScrollView>
)}
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.configScrollContent}
>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
attachments={draftInput.attachments}
workspaceAttachments={workspaceAttachments}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
attachments={draftInput.attachments}
workspaceAttachments={workspaceAttachments}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</View>
</FileDropZone>
);
}
+111 -147
View File
@@ -43,8 +43,6 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { FileDropZone } from "@/components/file-drop-zone";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -101,9 +99,7 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
@@ -766,6 +762,10 @@ interface ComposerProps {
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Callback to expose the addFiles function to parent components */
onAddFiles?: (addFiles: (files: UserComposerAttachment[]) => void) => void;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -977,6 +977,8 @@ export function Composer({
cwd,
clearDraft,
autoFocus = false,
onAddImages,
onAddFiles,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -1137,6 +1139,7 @@ export function Composer({
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback(
(images: ImageAttachment[]) => {
setSelectedAttachments((prev) => [
@@ -1147,6 +1150,11 @@ export function Composer({
[setSelectedAttachments],
);
useEffect(() => {
onAddImages?.(addImages);
}, [addImages, onAddImages]);
// Expose addFiles function to parent for drag-and-drop support
const addFiles = useCallback(
(files: UserComposerAttachment[]) => {
setSelectedAttachments((prev) => [...prev, ...files]);
@@ -1154,6 +1162,12 @@ export function Composer({
[setSelectedAttachments],
);
/* oxlint-disable react-hooks/exhaustive-deps */
useEffect(() => {
onAddFiles?.(addFiles);
}, [addFiles, onAddFiles]);
/* oxlint-enable react-hooks/exhaustive-deps */
const focusInput = useCallback(() => {
if (isNative) return;
focusWithRetries({
@@ -1349,13 +1363,14 @@ export function Composer({
addImages(newImages);
}, [addImages, pickImages]);
const uploadPickedFiles = useCallback(
async (files: PickedFile[]) => {
if (files.length === 0) return;
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
const handlePickFile = useCallback(async () => {
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
try {
const files = await pickFiles();
if (!files || files.length === 0) return;
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
if (oversized) {
@@ -1366,57 +1381,17 @@ export function Composer({
}
setIsUploadingFile(true);
try {
const uploaded = await uploadFileAttachments({ client, files });
addFiles(uploaded);
} catch (error) {
console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
} finally {
setIsUploadingFile(false);
}
},
[addFiles, client, t],
);
const handlePickFile = useCallback(async () => {
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
try {
const files = await pickFiles();
if (!files) return;
await uploadPickedFiles(files);
const uploaded = await uploadFileAttachments({ client, files });
addFiles(uploaded);
} catch (error) {
console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
} finally {
setIsUploadingFile(false);
}
}, [client, pickFiles, t, uploadPickedFiles]);
const handleGenericFilesDropped = useCallback(
async (items: DroppedItem[]) => {
try {
const files = await droppedItemsToPickedFiles(items);
if (files.length === 0) return;
if (!client || !isConnected) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
await uploadPickedFiles(files);
} catch (error) {
console.error("[Composer] Failed to upload dropped files:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
}
},
[client, isConnected, t, uploadPickedFiles],
);
}, [client, pickFiles, addFiles, t]);
const handleRemoveAttachment = useCallback(
(index: number) => {
@@ -1894,97 +1869,90 @@ export function Composer({
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<FileDropZone
onFilesDropped={addImages}
onGenericFilesDropped={handleGenericFilesDropped}
disabled={isSubmitBusy}
style={styles.dropZone}
>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
</View>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</FileDropZone>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
}
@@ -1994,10 +1962,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "column",
position: "relative",
},
dropZone: {
flex: 0,
width: "100%",
},
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
+12 -12
View File
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.31.0",
version: "0.30.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.30.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.31",
version: "3.0.29",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.31", "--acp"],
command: ["npx", "-y", "cline@3.0.29", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.112.0",
version: "2.110.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.112.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.110.0", "--acp"],
},
{
id: "codewhale",
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.159.1",
version: "0.158.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.159.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.158.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -182,10 +182,10 @@ const CATALOG_DATA = [
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.49.0",
version: "0.47.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.49.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.47.0", "--acp"],
},
{
id: "glm-acp-agent",
@@ -302,10 +302,10 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.31",
version: "1.0.30",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.31", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.30", "--acp"],
},
{
id: "qwen-code",
@@ -4,7 +4,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
@@ -14,7 +14,7 @@ export function useActiveWorktreeNewAction() {
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const activeGitWorkspace = useSessionStore((state) => {
const workingDir = useSessionStore((state) => {
if (!serverId || !workspaceId) {
return null;
}
@@ -22,30 +22,31 @@ export function useActiveWorktreeNewAction() {
if (!workspace || workspace.projectKind !== "git") {
return null;
}
return workspace;
return workspace.projectRootPath;
});
const workingDir = activeGitWorkspace?.projectRootPath ?? null;
const projectId = activeGitWorkspace?.projectId ?? null;
const displayName = activeGitWorkspace
? activeGitWorkspace.projectDisplayName ||
projectDisplayNameFromProjectId(activeGitWorkspace.projectId)
: null;
const displayName = useSessionStore((state) => {
if (!serverId || !workspaceId) {
return null;
}
const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId);
if (!workspace || workspace.projectKind !== "git") {
return null;
}
return workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId);
});
const handle = useCallback(() => {
if (!serverId || !workingDir) {
return false;
}
router.navigate(
buildNewWorkspaceRoute({
serverId,
sourceDirectory: workingDir,
buildHostNewWorkspaceRoute(serverId, workingDir, {
displayName: displayName ?? undefined,
projectId: projectId ?? undefined,
}) as never,
);
return true;
}, [serverId, workingDir, displayName, projectId]);
}, [serverId, workingDir, displayName]);
useKeyboardActionHandler({
handlerId: "worktree-new-active",
+38 -3
View File
@@ -2,9 +2,35 @@ import { useCallback, useRef } from "react";
import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
import { isWeb } from "@/constants/platform";
import { getMimeTypeFromPath } from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
import { getFileExtension, getMimeTypeFromPath } from "@/attachments/file-types";
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).slice(1) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
return base64ToUint8Array(base64);
}
async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
const dialog = getDesktopHost()?.dialog;
@@ -32,7 +58,16 @@ async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
for (const filePath of paths) {
const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath;
const mimeType = getMimeTypeFromPath(filePath);
const bytes = await readDesktopFileBytes(filePath);
// Copy into managed storage so we can read it through the existing secure IPC.
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: filePath,
extension: getFileExtension(filePath).slice(1) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
const bytes = base64ToUint8Array(base64);
result.push({ fileName, mimeType, bytes });
}
@@ -2,29 +2,27 @@ import { useCallback } from "react";
import { router } from "expo-router";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
export function useGlobalNewWorkspaceAction() {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const hosts = useHosts();
const handle = useCallback(() => {
if (hosts.length === 0) {
if (!serverId) {
return false;
}
router.navigate(buildNewWorkspaceRoute(serverId ? { serverId } : undefined) as never);
router.navigate(buildHostNewWorkspaceRoute(serverId) as never);
return true;
}, [hosts.length, serverId]);
}, [serverId]);
useKeyboardActionHandler({
handlerId: "workspace-new-global",
actions: WORKSPACE_NEW_ACTIONS,
enabled: hosts.length > 0,
enabled: serverId !== null,
priority: 0,
handle,
});
+1 -13
View File
@@ -1,7 +1,5 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { projectsQueryKey } from "@/hooks/use-projects";
import { useSessionStore } from "@/stores/session-store";
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
@@ -11,7 +9,6 @@ export function useOpenProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const queryClient = useQueryClient();
const canAddProject = useSessionStore((state) =>
normalizedServerId
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
@@ -22,7 +19,7 @@ export function useOpenProject(
return useCallback(
async (path: string) => {
const result = await openProjectDirectly({
return openProjectDirectly({
serverId: normalizedServerId,
projectPath: path,
isConnected,
@@ -31,14 +28,6 @@ export function useOpenProject(
addEmptyProject,
setHasHydratedWorkspaces,
});
// The aggregated projects query derives the project list from a fetch
// that now includes empty projects; refetch so a freshly-added project
// (no workspace yet) is immediately editable instead of only after a
// restart.
if (result.ok) {
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
}
return result;
},
[
addEmptyProject,
@@ -46,7 +35,6 @@ export function useOpenProject(
client,
isConnected,
normalizedServerId,
queryClient,
setHasHydratedWorkspaces,
],
);
@@ -169,7 +169,7 @@ export function useSidebarWorkspacesList(options?: {
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
@@ -181,9 +181,6 @@ export function useSidebarWorkspacesList(options?: {
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
// a freshly-added project from the sidebar.
store.setEmptyProjects(serverId, emptyProjects);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
-1
View File
@@ -388,7 +388,6 @@ describe("translation resources", () => {
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
expect(en.projectPicker.opening).toBe("Opening project...");
expect(en.projectPicker.searching).toBe("Searching...");
expect(en.projectPicker.empty).toBe("Start typing a path");
expect(en.branchSwitcher.currentBranch).toBe(
"Current branch: {{branchName}}. Press to switch branch.",
-1
View File
@@ -1054,7 +1054,6 @@ export const ar: TranslationResources = {
projectPicker: {
placeholder: "اكتب مسار الدليل...",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",
errors: {
directory_not_found: "لم يتم العثور على الدليل.",
-1
View File
@@ -1060,7 +1060,6 @@ export const en = {
projectPicker: {
placeholder: "Type a directory path...",
opening: "Opening project...",
searching: "Searching...",
empty: "Start typing a path",
errors: {
directory_not_found: "Directory not found.",
-1
View File
@@ -1087,7 +1087,6 @@ export const es: TranslationResources = {
projectPicker: {
placeholder: "Escriba una ruta de directorio...",
opening: "Proyecto de apertura...",
searching: "Buscando...",
empty: "Comience a escribir una ruta",
errors: {
directory_not_found: "No se encontró el directorio.",
-1
View File
@@ -1089,7 +1089,6 @@ export const fr: TranslationResources = {
projectPicker: {
placeholder: "Tapez un chemin de répertoire...",
opening: "Projet d'ouverture...",
searching: "Recherche en cours...",
empty: "Commencez à taper un chemin",
errors: {
directory_not_found: "Répertoire introuvable.",
-1
View File
@@ -1067,7 +1067,6 @@ export const ja: TranslationResources = {
projectPicker: {
placeholder: "ディレクトリパスを入力...",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",
errors: {
directory_not_found: "ディレクトリが見つかりません。",
-1
View File
@@ -1073,7 +1073,6 @@ export const ptBR: TranslationResources = {
projectPicker: {
placeholder: "Digite um caminho de diretório...",
opening: "Abrindo projeto...",
searching: "Buscando...",
empty: "Comece digitando um caminho",
errors: {
directory_not_found: "Diretório não encontrado.",
-1
View File
@@ -1077,7 +1077,6 @@ export const ru: TranslationResources = {
projectPicker: {
placeholder: "Введите путь к каталогу...",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",
errors: {
directory_not_found: "Каталог не найден.",
-1
View File
@@ -1039,7 +1039,6 @@ export const zhCN: TranslationResources = {
projectPicker: {
placeholder: "输入目录路径...",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",
errors: {
directory_not_found: "找不到目录。",
+103 -10
View File
@@ -14,7 +14,18 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { FileDropZone } from "@/components/file-drop-zone";
import { uploadFileAttachments } from "@/composer/actions";
import {
getMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes } from "@/hooks/use-file-picker";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
import type { UserComposerAttachment } from "@/attachments/types";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import type { ImageAttachment } from "@/composer/types";
import { getProviderIcon } from "@/components/provider-icons";
import {
ToastViewport,
@@ -695,6 +706,8 @@ function ChatAgentContent({
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const addFilesRef = useRef<((files: UserComposerAttachment[]) => void) | null>(null);
const clearOnAgentBlurRef = useRef<() => void>(() => {});
const wasPaneFocusedRef = useRef(isPaneFocused);
const reconnectToastArmedRef = useRef(false);
@@ -703,6 +716,54 @@ function ChatAgentContent({
routeKey: string;
reason: "initial-entry" | "resume";
} | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const handleAddFilesCallback = useCallback(
(addFiles: (files: UserComposerAttachment[]) => void) => {
addFilesRef.current = addFiles;
},
[],
);
const handleGenericFilesDropped = useCallback(
async (items: DroppedItem[]) => {
if (!client || !isConnected) return;
const nonImageItems = items.filter((item) => {
if (item.kind === "web-file") return !isRasterImageFile(item.file);
return !isRasterImagePath(item.path);
});
if (nonImageItems.length === 0) return;
try {
const files = await Promise.all(
nonImageItems.map(async (item) => {
if (item.kind === "web-file") {
return {
fileName: item.file.name,
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
bytes: new Uint8Array(await item.file.arrayBuffer()),
};
}
const fileName = item.path.split("/").pop() ?? item.path.split("\\").pop() ?? item.path;
const bytes = await readDesktopFileBytes(item.path);
return { fileName, mimeType: getMimeTypeFromPath(item.path), bytes };
}),
);
const uploaded = await uploadFileAttachments({ client, files });
addFilesRef.current?.(uploaded);
} catch (error) {
console.error("[AgentPanel] Failed to upload dropped files:", error);
toastApi.error(error instanceof Error ? error.message : "Failed to upload file");
}
},
[client, isConnected, toastApi],
);
const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
@@ -1060,6 +1121,10 @@ function ChatAgentContent({
dismiss={dismissToast}
streamViewRef={streamViewRef}
animatedContentStyle={animatedContentStyle}
handleFilesDropped={handleFilesDropped}
handleGenericFilesDropped={handleGenericFilesDropped}
handleAddImagesCallback={handleAddImagesCallback}
handleAddFilesCallback={handleAddFilesCallback}
handleComposerHeightChange={handleComposerHeightChange}
handleMessageSent={handleMessageSent}
showHistorySyncOverlay={showHistorySyncOverlay}
@@ -1085,6 +1150,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
dismiss,
streamViewRef,
animatedContentStyle,
handleFilesDropped,
handleGenericFilesDropped,
handleAddImagesCallback,
handleAddFilesCallback,
handleComposerHeightChange,
handleMessageSent,
showHistorySyncOverlay,
@@ -1106,6 +1175,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
dismiss: () => void;
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
animatedContentStyle: object[];
handleFilesDropped: (files: ImageAttachment[]) => void;
handleGenericFilesDropped: (items: DroppedItem[]) => void;
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
handleAddFilesCallback: (addFiles: (files: UserComposerAttachment[]) => void) => void;
handleComposerHeightChange: (height: number) => void;
handleMessageSent: () => void;
showHistorySyncOverlay: boolean;
@@ -1164,6 +1237,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
agentInputDraft={agentInputDraft}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={handleAddImagesCallback}
onAddFiles={handleAddFilesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
@@ -1177,19 +1252,25 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}>
<View style={styles.container}>
{contentContainer}
<FileDropZone
onFilesDropped={handleFilesDropped}
onGenericFilesDropped={handleGenericFilesDropped}
disabled={isArchivingCurrentAgent}
>
<View style={styles.container}>
{contentContainer}
{composerSection}
{composerSection}
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
</View>
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
</View>
</FileDropZone>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
@@ -1280,6 +1361,8 @@ const AgentComposerSection = memo(function AgentComposerSection({
agentInputDraft,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onAddFiles,
onComposerHeightChange,
onMessageSent,
}: {
@@ -1293,6 +1376,8 @@ const AgentComposerSection = memo(function AgentComposerSection({
agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
@@ -1316,6 +1401,8 @@ const AgentComposerSection = memo(function AgentComposerSection({
agentInputDraft={agentInputDraft}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onAddFiles={onAddFiles}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
@@ -1331,6 +1418,8 @@ function ActiveAgentComposer({
agentInputDraft,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onAddFiles,
onComposerHeightChange,
onMessageSent,
}: {
@@ -1342,6 +1431,8 @@ function ActiveAgentComposer({
agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
@@ -1487,6 +1578,8 @@ function ActiveAgentComposer({
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onAddFiles={onAddFiles}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
@@ -1,4 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
@@ -57,14 +58,13 @@ export async function fetchAggregatedProjects(
serverName: host.serverName,
isOnline,
workspaces: [],
emptyProjects: [],
},
error: null,
};
}
try {
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
const workspaces: WorkspaceDescriptor[] = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "name", direction: "asc" }],
});
@@ -74,7 +74,6 @@ export async function fetchAggregatedProjects(
serverName: host.serverName,
isOnline,
workspaces,
emptyProjects,
},
error: null,
};
@@ -85,7 +84,6 @@ export async function fetchAggregatedProjects(
serverName: host.serverName,
isOnline,
workspaces: [],
emptyProjects: [],
},
error: {
serverId: host.serverId,
@@ -1,33 +1,16 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
type EmptyProjectDescriptor,
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
export type FetchWorkspacesSort = NonNullable<
Parameters<DaemonClient["fetchWorkspaces"]>[0]
>["sort"];
export interface FetchWorkspaceDescriptorsResult {
workspaces: WorkspaceDescriptor[];
/**
* Project parents with no active workspaces. The daemon only rides these on
* the first page of `fetchWorkspaces`, so a freshly-added project that has no
* workspace yet shows up here and nowhere else — drop it and the project is
* invisible to anything that derives projects from workspaces alone.
*/
emptyProjects: EmptyProjectDescriptor[];
}
export async function fetchAllWorkspaceDescriptors(input: {
client: FetchWorkspacesClient;
sort: FetchWorkspacesSort;
}): Promise<FetchWorkspaceDescriptorsResult> {
const workspaces: WorkspaceDescriptor[] = [];
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
}): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
@@ -35,16 +18,12 @@ export async function fetchAllWorkspaceDescriptors(input: {
sort: input.sort,
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return { workspaces, emptyProjects: Array.from(emptyProjects.values()) };
return entries;
}
@@ -13,6 +13,7 @@ import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { FileDropZone } from "@/components/file-drop-zone";
import { HostStatusDot } from "@/components/host-status-dot";
import { HostPicker } from "@/components/hosts/host-picker";
import { ProjectIconView } from "@/components/project-icon-view";
@@ -56,7 +57,7 @@ import {
} from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { MessagePayload } from "@/composer/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
@@ -975,24 +976,11 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
function useNewWorkspaceHostSelector(initialServerId: string) {
const allHosts = useHosts();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const lastWorkspaceSelection = useLastWorkspaceSelection();
const normalizedInitialServerId = initialServerId.trim();
const routeInitialServerId = allServerIds.includes(normalizedInitialServerId)
? normalizedInitialServerId
: null;
const fallbackServerId =
lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId)
? lastWorkspaceSelection.serverId
: (allServerIds[0] ?? "");
const [manualServerId, setManualServerId] = useState<string | null>(null);
const [selectedServerId, setSelectedServerId] = useState(initialServerId);
const [hostPickerOpen, setHostPickerOpen] = useState(false);
const selectedServerId =
manualServerId && allServerIds.includes(manualServerId)
? manualServerId
: (routeInitialServerId ?? fallbackServerId);
const handleSelectHost = useCallback((id: string) => {
setManualServerId(id);
setSelectedServerId(id);
setHostPickerOpen(false);
}, []);
@@ -1008,7 +996,9 @@ function useNewWorkspaceHostSelector(initialServerId: string) {
allHosts,
allServerIds,
selectedServerId,
setSelectedServerId,
hostPickerOpen,
setHostPickerOpen,
handleSelectHost,
handleHostPickerOpenChange,
openHostPicker,
@@ -1195,7 +1185,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const showHostControl = host.allHosts.length > 1;
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
const badgePressableStyle = useCallback(
@@ -1242,7 +1231,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
</View>
);
const hostControl = showHostControl ? (
const hostControl = (
<View>
<HostPicker
hosts={host.allHosts}
@@ -1271,7 +1260,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
</Pressable>
</HostPicker>
</View>
) : null;
);
const isolationControl = isolation.canCreateWorktree ? (
<View>
@@ -1334,7 +1323,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
return isCompact ? (
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<FormRow>{projectControl}</FormRow>
{hostControl ? <FormRow>{hostControl}</FormRow> : null}
<FormRow>{hostControl}</FormRow>
{/* Keep fixed row height when git-only controls are hidden. */}
{isolationControl ? (
<FormRow>{isolationControl}</FormRow>
@@ -1808,6 +1797,14 @@ export function NewWorkspaceScreen({
[composerState, draftKey, ensureWorkspace, selectedServerId, t, toast],
);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const renderPickerOption = useCallback(
(props: {
option: ComboboxOptionType;
@@ -1965,43 +1962,46 @@ export function NewWorkspaceScreen({
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return (
<View style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<ReanimatedAnimated.View style={centeredStyle}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
{formStack}
<Composer
externalKeyboardShift
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={pendingAction !== null}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
footer={composerFooter}
/>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<ReanimatedAnimated.View style={centeredStyle}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
{formStack}
<Composer
externalKeyboardShift
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={pendingAction !== null}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
onAddImages={handleAddImagesCallback}
footer={composerFooter}
/>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</View>
</View>
</View>
</FileDropZone>
);
}
@@ -2072,7 +2072,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
height: BADGE_HEIGHT,
maxWidth: 240,
overflow: "hidden",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
@@ -2104,7 +2103,6 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.6,
},
badgeText: {
minWidth: 0,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
+14 -20
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import {
buildHostAgentDetailRoute,
buildHostNewWorkspaceRoute,
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
buildNewWorkspaceRoute,
buildOpenProjectRoute,
resolveKnownHostRoute,
buildSessionsRoute,
@@ -149,6 +149,19 @@ describe("workspace route parsing", () => {
).toBe("/h/local/workspace/b64_L3RtcC9yZXBv");
});
it("builds a global new workspace route without a source directory", () => {
expect(buildHostNewWorkspaceRoute("local")).toBe("/h/local/new");
});
it("builds a project shortcut new workspace route with initial project context", () => {
expect(
buildHostNewWorkspaceRoute("local", "/repo/project", {
displayName: "Project",
projectId: "project-1",
}),
).toBe("/h/local/new?dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
});
it("round-trips URL-safe IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"];
for (const id of ids) {
@@ -203,25 +216,6 @@ describe("global routes", () => {
it("buildSessionsRoute returns the all-host Sessions route", () => {
expect(buildSessionsRoute()).toBe("/sessions");
});
it("buildNewWorkspaceRoute returns the all-host New Workspace route", () => {
expect(buildNewWorkspaceRoute()).toBe("/new");
});
it("buildNewWorkspaceRoute accepts an initial host", () => {
expect(buildNewWorkspaceRoute({ serverId: "local" })).toBe("/new?serverId=local");
});
it("buildNewWorkspaceRoute accepts initial project context", () => {
expect(
buildNewWorkspaceRoute({
serverId: "local",
sourceDirectory: "/repo/project",
displayName: "Project",
projectId: "project-1",
}),
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
});
});
describe("host settings section slugs", () => {
+26 -33
View File
@@ -414,39 +414,6 @@ export function buildOpenProjectRoute() {
return "/open-project" as const;
}
interface NewWorkspaceRouteOptions {
serverId?: string;
sourceDirectory?: string;
displayName?: string;
projectId?: string;
}
function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
const params = new URLSearchParams();
const serverId = trimNonEmpty(options.serverId);
if (serverId) {
params.set("serverId", serverId);
}
if (options.sourceDirectory) {
params.set("dir", options.sourceDirectory);
}
if (options.displayName) {
params.set("name", options.displayName);
}
if (options.projectId) {
params.set("projectId", options.projectId);
}
return params.toString();
}
export function buildNewWorkspaceRoute(options: NewWorkspaceRouteOptions = {}) {
const query = buildNewWorkspaceSearch(options);
if (!query) {
return "/new" as const;
}
return `/new?${query}` as const;
}
export type KnownHostRouteResolution =
| { kind: "render" }
| { kind: "redirect"; href: ReturnType<typeof buildOpenProjectRoute> | "/welcome" };
@@ -467,6 +434,32 @@ export function resolveKnownHostRoute(input: {
return { kind: "redirect", href: "/welcome" };
}
export function buildHostNewWorkspaceRoute(
serverId: string,
sourceDirectory?: string,
options?: { displayName?: string; projectId?: string },
) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
return "/" as const;
}
const params = new URLSearchParams();
if (sourceDirectory) {
params.set("dir", sourceDirectory);
}
if (options?.displayName) {
params.set("name", options.displayName);
}
if (options?.projectId) {
params.set("projectId", options.projectId);
}
const query = params.toString();
if (!query) {
return `${base}/new` as const;
}
return `${base}/new?${query}` as const;
}
export const SETTINGS_SECTION_SLUGS = [
"general",
"daemon",
-32
View File
@@ -514,36 +514,4 @@ describe("buildProjects", () => {
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
});
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [],
emptyProjects: [
{
projectId: "/repo/fresh",
projectDisplayName: "fresh",
projectCustomName: null,
projectRootPath: "/repo/fresh",
projectKind: "git",
},
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("/repo/fresh");
expect(summary?.totalWorkspaceCount).toBe(0);
expect(summary?.hosts).toHaveLength(1);
// repoRoot must be non-empty or the project settings screen treats the host
// as non-editable and shows its empty state.
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
});
});
+6 -23
View File
@@ -1,4 +1,4 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
@@ -37,8 +37,6 @@ export interface ProjectHost {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
/** Project parents with no active workspaces, so they still surface as projects. */
emptyProjects?: EmptyProjectDescriptor[];
}
export interface BuildProjectsInput {
@@ -56,9 +54,6 @@ interface HostGroup {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
// Repo root for a project parent that has no workspaces yet. Without it the
// host's repoRoot resolves to "" and the project reads as non-editable.
fallbackRepoRoot: string;
}
interface ProjectGroup {
@@ -86,13 +81,7 @@ function findProjectCustomName(
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
sessions: [{ serverId: host.serverId, workspaces: host.workspaces }],
}),
});
}
@@ -105,14 +94,14 @@ function deriveGithubUrl(projectKey: string): string | undefined {
return `https://github.com/${match[1]}/${match[2]}`;
}
function resolveHostRepoRoot(group: HostGroup): string {
for (const workspace of group.workspaces) {
function resolveHostRepoRoot(workspaces: WorkspaceDescriptor[]): string {
for (const workspace of workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
return workspaces[0]?.projectRootPath ?? "";
}
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
@@ -126,7 +115,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
}
function toHostEntry(group: HostGroup): ProjectHostEntry {
const repoRoot = resolveHostRepoRoot(group);
const repoRoot = resolveHostRepoRoot(group.workspaces);
const canonical =
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
group.workspaces[0];
@@ -170,11 +159,6 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
for (const host of input.hosts) {
const emptyRepoRootByProjectKey = new Map<string, string>();
for (const emptyProject of host.emptyProjects ?? []) {
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
}
const hostProjects = buildHostProjectEntries(host);
for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
@@ -198,7 +182,6 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
});
}
}
@@ -40,7 +40,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
}),
).toBe("/new?serverId=server-1&dir=%2Frepo&name=Project&projectId=project-1");
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1");
});
it("redirects to the new workspace route when no sibling workspace target exists", () => {
@@ -58,7 +58,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
}),
).toBe("/new?serverId=server-1&dir=%2Frepo&name=Project&projectId=project-1");
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1");
});
it("redirects to the new workspace route instead of another workspace", () => {
@@ -78,7 +78,7 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
archivedWorkspaceId: "/notes",
workspaces,
}),
).toBe("/new?serverId=server-1&dir=%2Fnotes&name=Project&projectId=notes");
).toBe("/h/server-1/new?dir=%2Fnotes&name=Project&projectId=notes");
});
});
@@ -136,6 +136,6 @@ describe("redirectIfArchivingActiveWorkspace", () => {
),
).toBe(true);
expect(routes).toEqual(["/new?serverId=server-1&dir=%2Frepo&name=Project&projectId=project-1"]);
expect(routes).toEqual(["/h/server-1/new?dir=%2Frepo&name=Project&projectId=project-1"]);
});
});
@@ -1,6 +1,6 @@
import type { Href } from "expo-router";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostRootRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
export function buildWorkspaceArchiveRedirectRoute(input: {
@@ -23,9 +23,7 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
return buildHostRootRoute(input.serverId);
}
return buildNewWorkspaceRoute({
serverId: input.serverId,
sourceDirectory,
return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, {
displayName: archivedWorkspace.projectDisplayName,
projectId: archivedWorkspace.projectId,
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.102-beta.1",
"@getpaseo/protocol": "0.1.102-beta.1",
"@getpaseo/server": "0.1.102-beta.1",
"@getpaseo/client": "0.1.101",
"@getpaseo/protocol": "0.1.101",
"@getpaseo/server": "0.1.101",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.102-beta.1",
"@getpaseo/relay": "0.1.102-beta.1",
"@getpaseo/protocol": "0.1.101",
"@getpaseo/relay": "0.1.101",
"zod": "^4.4.3"
},
"devDependencies": {
+2 -6
View File
@@ -1886,9 +1886,7 @@ export class DaemonClient {
cwd,
},
responseType: "open_project_response",
// Large local repos (e.g. a big monorepo/brain checkout) need >10s for the
// daemon to resolve the path, detect git, and materialize the workspace.
timeout: 60000,
timeout: 10000,
});
}
@@ -3514,9 +3512,7 @@ export class DaemonClient {
limit: options.limit,
},
responseType: "directory_suggestions_response",
// Home-tree scans on large home dirs can take several seconds; don't cut
// the suggestion request off early (it would surface as an empty list).
timeout: 30000,
timeout: 10000,
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -1,65 +0,0 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { copyAttachmentFileToManagedStorage } from "./attachments";
const originalPaseoHome = process.env.PASEO_HOME;
let testHome: string | null = null;
async function useTempPaseoHome(): Promise<string> {
testHome = await mkdtemp(path.join(os.tmpdir(), "paseo-desktop-attachments-"));
process.env.PASEO_HOME = testHome;
return testHome;
}
describe("desktop attachment files", () => {
afterEach(async () => {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
if (testHome) {
await rm(testHome, { recursive: true, force: true });
testHome = null;
}
});
it("accepts dot-prefixed picker extensions for managed copies", async () => {
const paseoHome = await useTempPaseoHome();
const sourcePath = path.join(paseoHome, "report.md");
await writeFile(sourcePath, "# Report\n");
const result = await copyAttachmentFileToManagedStorage({
attachmentId: "att_markdown",
sourcePath,
extension: ".md",
});
expect(result).toEqual({
path: path.join(paseoHome, "desktop-attachments", "att_markdown.md"),
byteSize: 9,
});
await expect(readFile(result.path, "utf8")).resolves.toBe("# Report\n");
});
it("normalizes legacy bare extensions for managed copies", async () => {
const paseoHome = await useTempPaseoHome();
const sourcePath = path.join(paseoHome, "report.md");
await writeFile(sourcePath, "# Report\n");
const result = await copyAttachmentFileToManagedStorage({
attachmentId: "att_markdown_legacy",
sourcePath,
extension: "md",
});
expect(result).toEqual({
path: path.join(paseoHome, "desktop-attachments", "att_markdown_legacy.md"),
byteSize: 9,
});
await expect(readFile(result.path, "utf8")).resolves.toBe("# Report\n");
});
});
+2 -3
View File
@@ -40,11 +40,10 @@ function normalizeExtension(value: unknown): string {
throw new Error("Attachment extension must be a string.");
}
const normalized = value.trim().toLowerCase();
const extension = normalized.startsWith(".") ? normalized : `.${normalized}`;
if (!EXTENSION_PATTERN.test(extension)) {
if (!EXTENSION_PATTERN.test(normalized)) {
throw new Error(`Invalid attachment extension: ${value}`);
}
return extension;
return normalized;
}
async function buildManagedAttachmentPath(input: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"files": [
"dist",
"!dist/**/*.map"
@@ -44,7 +44,6 @@
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@replit/codemirror-lang-csharp": "^6.2.0",
"lezer-elixir": "^1.1.2"
},
"devDependencies": {
@@ -62,19 +62,6 @@ describe("highlightCode", () => {
expect(stringToken?.style).toBe("string");
});
it("highlights C# code", () => {
const code = 'class Greeter {\n string message = "hello";\n}';
const result = highlightCode(code, "test.cs");
expect(result).toHaveLength(3);
const classToken = result[0].find((t) => t.text === "class");
expect(classToken?.style).toBe("keyword");
const stringToken = result[1].find((t) => t.text.includes("hello"));
expect(stringToken?.style).toBe("string");
});
it("highlights TSX code with correct dialect", () => {
const code = 'const el = <div className="test">hello</div>;';
const result = highlightCode(code, "test.tsx");
@@ -15,7 +15,6 @@ describe("isLanguageSupported", () => {
expect(isLanguageSupported("test.java")).toBe(true);
expect(isLanguageSupported("test.swift")).toBe(true);
expect(isLanguageSupported("test.dart")).toBe(true);
expect(isLanguageSupported("test.cs")).toBe(true);
expect(isLanguageSupported("test.ex")).toBe(true);
});
@@ -54,7 +53,6 @@ describe("getSupportedExtensions", () => {
expect(extensions).toContain("rs");
expect(extensions).toContain("swift");
expect(extensions).toContain("dart");
expect(extensions).toContain("cs");
expect(extensions).toContain("json");
});
});
-3
View File
@@ -14,7 +14,6 @@ import { parser as phpParser } from "@lezer/php";
import { parser as rustParser } from "@lezer/rust";
import { parser as xmlParser } from "@lezer/xml";
import { parser as yamlParser } from "@lezer/yaml";
import { csharpLanguage } from "@replit/codemirror-lang-csharp";
import { parser as elixirParser } from "lezer-elixir";
import type { Parser } from "@lezer/common";
@@ -63,8 +62,6 @@ const parsersByExtension: Record<string, Parser> = {
swift: StreamLanguage.define(swift).parser,
// Dart
dart: StreamLanguage.define(dart).parser,
// C#
cs: csharpLanguage.parser,
// Elixir
ex: elixirParser,
exs: elixirParser,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -3,7 +3,6 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [
"codex",
"copilot",
"kiro",
"minimax",
"omp",
"opencode",
"pi",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.102-beta.1",
"version": "0.1.101",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.1",
"@getpaseo/highlight": "0.1.102-beta.1",
"@getpaseo/protocol": "0.1.102-beta.1",
"@getpaseo/relay": "0.1.102-beta.1",
"@getpaseo/client": "0.1.101",
"@getpaseo/highlight": "0.1.101",
"@getpaseo/protocol": "0.1.101",
"@getpaseo/relay": "0.1.101",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -3702,11 +3702,7 @@ export class AgentManager {
return undefined;
}
try {
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: config.cwd,
force: false,
});
const catalog = await client.fetchCatalog({ cwd: config.cwd, force: false });
return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id;
} catch {
// Provider may not support model listing — leave model undefined.
@@ -643,18 +643,11 @@ export interface AgentSession {
} | null;
}
export type FetchCatalogOptions =
| {
scope: "global";
force: boolean;
timeoutMs?: number;
}
| {
scope: "workspace";
cwd: string;
force: boolean;
timeoutMs?: number;
};
export interface FetchCatalogOptions {
cwd: string;
force: boolean;
timeoutMs?: number;
}
export interface ProviderCatalog {
models: AgentModelDefinition[];
@@ -810,7 +810,6 @@ describe("model merging", () => {
});
const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -846,7 +845,6 @@ describe("model merging", () => {
});
const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -885,7 +883,6 @@ describe("model merging", () => {
});
const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -920,7 +917,6 @@ describe("model merging", () => {
});
const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -963,7 +959,6 @@ describe("model merging", () => {
});
const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1014,7 +1009,6 @@ describe("model merging", () => {
});
const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1062,7 +1056,6 @@ describe("model merging", () => {
});
const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1102,7 +1095,6 @@ describe("model merging", () => {
});
const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1155,7 +1147,6 @@ describe("model merging", () => {
});
const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1194,7 +1185,6 @@ describe("model merging", () => {
const registry = buildProviderRegistry(logger);
const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1235,7 +1225,6 @@ describe("model merging", () => {
const client = registry.codex.createClient(logger);
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1270,7 +1259,6 @@ describe("model merging", () => {
const client = registry.claude.createClient(logger);
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1299,7 +1287,6 @@ describe("model merging", () => {
});
const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models",
force: false,
});
@@ -1317,7 +1304,6 @@ describe("fetchCatalog", () => {
const registry = buildProviderRegistry(logger);
const catalog = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/catalog",
force: false,
});
@@ -1341,7 +1327,6 @@ describe("fetchCatalog", () => {
});
const catalog = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/catalog",
force: false,
});
@@ -1360,7 +1345,6 @@ describe("fetchCatalog", () => {
});
const catalog = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/catalog",
force: false,
});
@@ -1,4 +1,3 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest";
@@ -12,11 +11,7 @@ import type {
ResolveAgentCreateConfigInput,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import {
GLOBAL_PROVIDER_SNAPSHOT_KEY,
ProviderSnapshotManager,
resolveSnapshotCwd,
} from "./provider-snapshot-manager.js";
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
const TEST_CAPABILITIES = {
@@ -493,7 +488,6 @@ describe("ProviderSnapshotManager public surface", () => {
try {
const result = await manager.getProviderDiagnostic("codex");
expect(fetchCatalog).toHaveBeenCalledTimes(1);
expect(fetchCatalog.mock.calls[0]?.[0]).toMatchObject({ scope: "global", force: true });
expect(result.diagnostic).toContain("Models: 1");
expect(result.diagnostic).toContain("Status: Ready");
} finally {
@@ -1069,60 +1063,6 @@ describe("ProviderSnapshotManager lifecycle", () => {
});
describe("ProviderSnapshotManager cwd routing", () => {
test("settings refresh passes the semantic global scope to providers", async () => {
const fetchCatalog = vi.fn(async () => ({
models: [] as AgentModelDefinition[],
modes: [] as AgentMode[],
}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: vi.fn(async () => true),
fetchCatalog,
}),
},
});
try {
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
expect(fetchCatalog.mock.calls[0]?.[0]).toMatchObject({ scope: "global", force: true });
} finally {
manager.destroy();
}
});
test("global snapshot does not satisfy an explicit home workspace read", async () => {
const fetchCatalog = vi.fn(async () => ({
models: [] as AgentModelDefinition[],
modes: [] as AgentMode[],
}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: vi.fn(async () => true),
fetchCatalog,
}),
},
});
try {
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
await manager.listProviders({ cwd: homedir(), providers: ["codex"], wait: true });
expect(fetchCatalog.mock.calls.map((call) => call[0])).toEqual([
expect.objectContaining({ scope: "global", force: true }),
expect.objectContaining({
scope: "workspace",
cwd: resolveSnapshotCwd(homedir()),
force: false,
}),
]);
} finally {
manager.destroy();
}
});
test("different cwd keys produce independent snapshots", () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
@@ -1146,7 +1086,7 @@ describe("ProviderSnapshotManager cwd routing", () => {
}
});
test("getSnapshot called with no cwd resolves to the global snapshot key", () => {
test("getSnapshot called with no cwd resolves to the home snapshot key", () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
@@ -1163,7 +1103,13 @@ describe("ProviderSnapshotManager cwd routing", () => {
manager.getSnapshot();
manager.applyMutableProviderConfig({});
const cwds = listener.mock.calls.map((call) => call[1]);
expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY);
// applyMutableProviderConfig emits change for each primed cwd; the home
// snapshot must be present.
expect(cwds.length).toBeGreaterThanOrEqual(1);
for (const cwd of cwds) {
expect(typeof cwd).toBe("string");
expect(cwd.length).toBeGreaterThan(0);
}
} finally {
manager.destroy();
}

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