mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c0033f22 | ||
|
|
444214cbe2 | ||
|
|
7f6403f517 | ||
|
|
be8c2a54ce | ||
|
|
c6c030ab26 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
sha256-Q8PQXknQerl0SOejZ+q/uCSqQwH541AHJ4bzu5Dap+A=
|
||||
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=
|
||||
|
||||
Generated
+23
-1390
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
@@ -40,19 +38,6 @@ test.describe("New workspace entry points", () => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-new-workspace-host",
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
@@ -63,43 +48,11 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(globalButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expect(page.getByTestId("host-chooser")).toHaveCount(0);
|
||||
|
||||
// The screen is up: its project picker trigger is the canonical landmark.
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the New Workspace screen hides the host selector when there is only one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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", {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1054,7 +1054,6 @@ export const ar: TranslationResources = {
|
||||
projectPicker: {
|
||||
placeholder: "اكتب مسار الدليل...",
|
||||
opening: "افتتاح المشروع...",
|
||||
searching: "جارٍ البحث...",
|
||||
empty: "ابدأ بكتابة المسار",
|
||||
errors: {
|
||||
directory_not_found: "لم يتم العثور على الدليل.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1067,7 +1067,6 @@ export const ja: TranslationResources = {
|
||||
projectPicker: {
|
||||
placeholder: "ディレクトリパスを入力...",
|
||||
opening: "プロジェクトを開いています...",
|
||||
searching: "検索中...",
|
||||
empty: "パスを入力してください",
|
||||
errors: {
|
||||
directory_not_found: "ディレクトリが見つかりません。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1077,7 +1077,6 @@ export const ru: TranslationResources = {
|
||||
projectPicker: {
|
||||
placeholder: "Введите путь к каталогу...",
|
||||
opening: "Открытие проекта...",
|
||||
searching: "Идет поиск...",
|
||||
empty: "Начните вводить путь",
|
||||
errors: {
|
||||
directory_not_found: "Каталог не найден.",
|
||||
|
||||
@@ -1039,7 +1039,6 @@ export const zhCN: TranslationResources = {
|
||||
projectPicker: {
|
||||
placeholder: "输入目录路径...",
|
||||
opening: "正在打开 project...",
|
||||
searching: "正在搜索...",
|
||||
empty: "开始输入路径",
|
||||
errors: {
|
||||
directory_not_found: "找不到目录。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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,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,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user