mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
5 Commits
sonnet-5-s
...
feat/daemo
| 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
|
||||
205
.github/workflows/docker.yml
vendored
205
.github/workflows/docker.yml
vendored
@@ -1,205 +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 version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
description: "Publish the image to GHCR. Manual publishes require paseo_version."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
source_build:
|
||||
description: "Build from the checked-out source tree instead of npm."
|
||||
required: false
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
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 }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
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
|
||||
publish_latest=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
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_PASEO_VERSION}" || "${INPUT_PASEO_VERSION}" == "latest" ]]; then
|
||||
echo "::error::paseo_version is required for manual Docker publishes."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
fi
|
||||
|
||||
if [[ "${install_version}" == *-* ]]; then
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||
true)
|
||||
source_build=true
|
||||
;;
|
||||
false)
|
||||
source_build=false
|
||||
;;
|
||||
auto)
|
||||
if [[ "${prerelease}" == "true" ]]; then
|
||||
source_build=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::source_build must be auto, true, or false."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
fi
|
||||
|
||||
check_tag="${image}:check-${GITHUB_SHA::12}"
|
||||
publish_tags="${image}:${install_version}"
|
||||
if [[ "${publish_latest}" == "true" ]]; then
|
||||
publish_tags="${publish_tags}"$'\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
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,56 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
|
||||
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
|
||||
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
|
||||
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
|
||||
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
|
||||
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
|
||||
### Improved
|
||||
|
||||
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
|
||||
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results ([#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 scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
|
||||
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
|
||||
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
|
||||
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
|
||||
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
|
||||
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -33,7 +33,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
@@ -45,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 |
|
||||
@@ -73,7 +71,6 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Paseo is an opinionated product maintained by one person right now.
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
|
||||
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
|
||||
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
|
||||
## Becoming a maintainer
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
|
||||
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
|
||||
|
||||
Here's the progression:
|
||||
|
||||
1. Get involved in the community: answer questions in Discord and on GitHub
|
||||
2. Triage bugs: replicate and help fix them
|
||||
3. Work on maintainer-approved features
|
||||
|
||||
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
|
||||
|
||||
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
|
||||
|
||||
## Pull requests
|
||||
|
||||
✅ Will be accepted
|
||||
Follow these rules if you want your PR to be merged:
|
||||
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
@@ -32,25 +18,15 @@ Learning on the job is fine, I do not care how many years of experience you have
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
⛔️ Will be rejected
|
||||
Your PR will be closed if you do any of these:
|
||||
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Add a feature or design change that wasn't discussed first
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
## Requesting features
|
||||
|
||||
If you need a feature implemented, create a Github issue or a thread in Discord.
|
||||
|
||||
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
|
||||
|
||||
## AI assistance
|
||||
|
||||
Using AI to help write code is fine, but you must:
|
||||
|
||||
- Ensure your agents read the docs
|
||||
- Understand the code you submit
|
||||
- Review and test the code yourself
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
|
||||
15
README.md
15
README.md
@@ -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
|
||||
@@ -169,8 +169,6 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
@@ -164,12 +164,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -207,17 +202,13 @@ Set these to select OpenAI instead of local speech:
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"voice": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -226,7 +217,7 @@ OpenAI speech can be configured under `providers.openai`. STT and TTS resolve in
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
|
||||
@@ -49,11 +49,37 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Expo Router
|
||||
### Expo Router layout ownership
|
||||
|
||||
Route ownership, startup restore, and native blank-screen gotchas live in
|
||||
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
|
||||
startup routing, remembered workspace restore, or active workspace selection.
|
||||
Each layout owns only the routes directly inside its directory. In the root
|
||||
layout, register `h/[serverId]`; do not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
|
||||
routes with its own nested stack and relative screen names:
|
||||
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
|
||||
warns with `[Layout children]: No route named ...` when a layout registers
|
||||
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
|
||||
leave a nested index route mounted without its local dynamic params and render a
|
||||
blank screen.
|
||||
|
||||
Do not paper over missing required route params by reading global params in the
|
||||
leaf. Required dynamic params belong to the matched route. If
|
||||
`useLocalSearchParams()` misses one, fix the layout ownership.
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree. Put shared route policy in
|
||||
`src/navigation`, `src/utils`, or another non-route directory.
|
||||
|
||||
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
|
||||
workspace for that host after the workspace-selection store hydrates unless the
|
||||
host's hydrated workspace list proves that workspace is gone; hosts without a
|
||||
remembered workspace go to `open-project`.
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId`/
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
### iOS simulator preview service
|
||||
|
||||
|
||||
239
docs/docker.md
239
docs/docker.md
@@ -1,239 +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`.
|
||||
|
||||
To replace a Docker image in place without rebuilding desktop, APK, or EAS
|
||||
mobile release artifacts, dispatch the Docker workflow manually instead of
|
||||
pushing a `v*` release tag:
|
||||
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
|
||||
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.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Expo Router
|
||||
|
||||
Paseo's mobile route tree is fragile because Expo Router and React Navigation do
|
||||
not fail loudly when a nested native route is mounted under the wrong layout. The
|
||||
usual symptom is a white or blank native screen with no JavaScript crash.
|
||||
|
||||
Read this before changing `packages/app/src/app`, startup routing, remembered
|
||||
workspace restore, or active workspace selection.
|
||||
|
||||
## Ownership
|
||||
|
||||
Each layout owns only the routes directly inside its directory.
|
||||
|
||||
- The root layout registers `h/[serverId]`.
|
||||
- The root layout does not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index`.
|
||||
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
|
||||
relative screen names: `index`, `workspace/[workspaceId]/index`,
|
||||
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
|
||||
|
||||
Expo Router warns with `[Layout children]: No route named ...` when a layout
|
||||
registers grandchildren. Treat that warning as a route-tree bug. On native, that
|
||||
shape can leave a nested index route mounted without its local dynamic params and
|
||||
render a blank screen.
|
||||
|
||||
## Startup
|
||||
|
||||
The root `/` route chooses a host boundary. It does not jump directly into a host
|
||||
leaf.
|
||||
|
||||
- Good: `/` -> `/h/[serverId]`
|
||||
- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]`
|
||||
|
||||
`/h/[serverId]` is the host home route. The host index restores the last
|
||||
remembered workspace for that host after the remembered selection has hydrated
|
||||
and the workspace has not been proven missing. If there is no restorable
|
||||
workspace, it goes to global `/open-project`.
|
||||
|
||||
This restore is based on the last navigated workspace, not current connection
|
||||
status. Do not redirect to another online host just because the remembered host
|
||||
is still connecting or offline; the workspace screen owns that offline/loading
|
||||
state.
|
||||
|
||||
This split is deliberate. The host layout must mount first so native local
|
||||
dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||
leaf workspace URL.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
root host route and pass the nested workspace screen, or Expo Router can append
|
||||
extra hidden workspace deck entries.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
Do not paper over missing required params by reading global params in the leaf.
|
||||
If `useLocalSearchParams()` misses a required param, fix layout ownership or the
|
||||
startup route shape.
|
||||
|
||||
Use the host route context for host-owned leaves that need the host id after
|
||||
`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an
|
||||
unmatched tree by guessing from global state.
|
||||
|
||||
## App Directory
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree.
|
||||
|
||||
Put shared route policy in `src/navigation`, `src/utils`, stores, or another
|
||||
non-route directory.
|
||||
|
||||
## Native Stack
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId` and
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
route-tree state, so a real regression should launch native with seeded persisted
|
||||
state:
|
||||
|
||||
1. Seed `paseo:last-workspace-route-selection` with a valid
|
||||
`{ serverId, workspaceId }`.
|
||||
2. Launch the native app cold.
|
||||
3. Assert a real screen is visible, not the blank tree.
|
||||
4. Assert no `[Layout children]` warning appears.
|
||||
|
||||
The pure policy tests should still enforce the boundary split:
|
||||
|
||||
- root startup with a saved workspace returns `/h/[serverId]`;
|
||||
- host index with the same saved workspace returns
|
||||
`/h/[serverId]/workspace/[workspaceId]`;
|
||||
- host index with no restorable workspace returns `/open-project`.
|
||||
|
||||
## Checklist
|
||||
|
||||
Before landing route changes:
|
||||
|
||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||
- [ ] Did an app-wide route return to a workspace? Use
|
||||
`navigateToHostWorkspaceRoute()`.
|
||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||
before stores, themes, or rendering.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -273,31 +271,9 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
For Docker-only retries, **do not push or force-push a `v*` release tag**.
|
||||
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
|
||||
and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
desktop, APK, or EAS release builders. The Docker exception is safe because the
|
||||
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
|
||||
not check out or move the `v*` release tag.
|
||||
|
||||
To retry a failed non-Docker release workflow, push a retry tag on the commit
|
||||
you want to build. Reusing the same tag name is expected: move it with
|
||||
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
|
||||
you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||
image.
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i
|
||||
|
||||
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-uGqJm/y14KJYBwutBkPwxhNbStzpeeVx2PcB081r4nk=
|
||||
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=
|
||||
|
||||
1671
package-lock.json
generated
1671
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.101",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
@@ -6,20 +6,8 @@ import {
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
waitForScrollableChat,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
@@ -37,89 +25,6 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-away-",
|
||||
title: "Scroll-away anchor",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "emit 120 agent stream updates for scroll-away setup.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test.");
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 });
|
||||
|
||||
const finalMetrics = await readScrollMetrics(page);
|
||||
expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await withWorkspace({
|
||||
prefix: "stream-scroll-away-delayed-history-",
|
||||
});
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await selectModel(page, "Five minute stream");
|
||||
|
||||
const prompt = "Stream for delayed authoritative history scroll-away test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
@@ -131,7 +36,6 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await scrollAgentChatToBottom(page);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentOptions,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
}>({
|
||||
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
await provide(async (options) => {
|
||||
const session = await seedMockAgentWorkspace(options);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
},
|
||||
});
|
||||
|
||||
async function openAssistantForkMenu(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await scrollAgentChatToBottom(page);
|
||||
return page.getByTestId("assistant-fork-menu-trigger").count();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("Chat history");
|
||||
}
|
||||
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-",
|
||||
title: "Assistant fork tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-assistant-fork-host",
|
||||
label: "Secondary host",
|
||||
// The host does not need to be reachable; this pins that the draft-scoped
|
||||
// attachment survives changing the selected target host.
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-workspace-",
|
||||
title: "Assistant fork workspace",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page
|
||||
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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-" });
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -148,43 +142,6 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||
page: Page,
|
||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const summary = await page
|
||||
.locator('[data-testid^="workspace-deck-entry-"]')
|
||||
.evaluateAll((elements, target) => {
|
||||
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
|
||||
const entries = elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
testId: element.getAttribute("data-testid"),
|
||||
hasLayout: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
});
|
||||
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
|
||||
return {
|
||||
totalDeckEntryCount: entries.length,
|
||||
currentWorkspaceEntryCount: currentEntries.length,
|
||||
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
|
||||
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
|
||||
};
|
||||
}, input);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalDeckEntryCount: input.expectedDeckEntryCount,
|
||||
currentWorkspaceEntryCount: 1,
|
||||
visibleCurrentWorkspaceEntryCount: 1,
|
||||
hiddenCurrentWorkspaceEntryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
@@ -351,64 +308,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
|
||||
try {
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-a-",
|
||||
title: "Autocomplete new route A",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-b-",
|
||||
title: "Autocomplete new route B",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-c-",
|
||||
title: "Autocomplete new route C",
|
||||
}),
|
||||
);
|
||||
|
||||
const [first, second, third] = sessions;
|
||||
|
||||
await openAgentRoute(page, first);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: first.workspaceId,
|
||||
});
|
||||
|
||||
await composerLocator(page).fill("/");
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ hasText: "/help", visible: true })
|
||||
.first();
|
||||
await expect(popover).toBeInViewport({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -4,11 +4,8 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopAboutSettings,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickCheckForUpdates,
|
||||
expectPendingUpdateCheckResult,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
@@ -48,21 +45,6 @@ test.describe("Desktop updates", () => {
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
|
||||
test("manual check reports a found update while it downloads", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
updateReadyToInstall: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopAboutSettings(page);
|
||||
|
||||
await clickCheckForUpdates(page);
|
||||
|
||||
await expectPendingUpdateCheckResult(page, "1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
|
||||
export interface ScrollMetrics {
|
||||
offsetY: number;
|
||||
@@ -55,25 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||
const chatScroll = getVisibleChatScroll(page);
|
||||
await chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
@@ -86,65 +66,3 @@ export async function waitForContentGrowth(
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function waitForScrollableChat(
|
||||
page: Page,
|
||||
input: { minScrollableDistance: number; timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight - metrics.viewportHeight;
|
||||
},
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
.toBeGreaterThan(input.minScrollableDistance);
|
||||
}
|
||||
|
||||
export async function scrollChatAwayFromBottom(
|
||||
page: Page,
|
||||
input: { deltaY: number; minDistanceFromBottom: number },
|
||||
): Promise<ScrollMetrics> {
|
||||
const scroll = getVisibleChatScroll(page);
|
||||
const box = await scroll.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(input.minDistanceFromBottom);
|
||||
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number },
|
||||
): Promise<void> {
|
||||
const durationMs = input?.durationMs ?? 2_000;
|
||||
const sampleIntervalMs = input?.sampleIntervalMs ?? 250;
|
||||
const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX;
|
||||
const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = [];
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < durationMs) {
|
||||
await page.waitForTimeout(sampleIntervalMs);
|
||||
const metrics = await readScrollMetrics(page);
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
offsetY: metrics.offsetY,
|
||||
contentHeight: metrics.contentHeight,
|
||||
});
|
||||
expect(
|
||||
metrics.offsetY,
|
||||
JSON.stringify({ baseline, samples: samples.slice(-12) }),
|
||||
).toBeLessThanOrEqual(baseline.offsetY + tolerancePx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreatedAgentTimelineGate {
|
||||
release(): void;
|
||||
waitForCreatedAgent(): Promise<string>;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
return message.payload && typeof message.payload === "object"
|
||||
? (message.payload as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
let createdAgentId: string | null = null;
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
let resolveForwardedResponse: (() => void) | null = null;
|
||||
const createdAgentSeen = new Promise<string>((resolve) => {
|
||||
resolveCreatedAgent = resolve;
|
||||
});
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
const forwardedResponse = new Promise<void>((resolve) => {
|
||||
resolveForwardedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
const forwardToClient = (message: WebSocketMessage) => {
|
||||
ws.send(message);
|
||||
resolveForwardedResponse?.();
|
||||
};
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
|
||||
const agentId = payload.agentId;
|
||||
if (typeof agentId === "string") {
|
||||
createdAgentId = agentId;
|
||||
resolveCreatedAgent?.(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMessage?.type === "fetch_agent_timeline_response") {
|
||||
const agentId = payload?.agentId;
|
||||
const direction = payload?.direction;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
typeof agentId === "string" &&
|
||||
agentId === createdAgentId &&
|
||||
direction === "tail"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
forwardToClient(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => forwardToClient(message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreatedAgent: () => createdAgentSeen,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
@@ -342,17 +342,9 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelButton = page
|
||||
.getByRole("button", { name: /Select model/i })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await modelButton.isVisible().catch(() => false)) {
|
||||
await modelButton.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
|
||||
@@ -88,13 +88,11 @@ export async function archiveAgentFromDaemon(
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
},
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
const result = await client.fetchAgent(agentId);
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,8 +3,7 @@ import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -53,7 +52,6 @@ export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
updateReadyToInstall?: boolean;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
@@ -171,7 +169,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: cfg.updateReadyToInstall ?? true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
@@ -278,33 +276,12 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Check" }).click();
|
||||
}
|
||||
|
||||
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
||||
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
||||
await expect(
|
||||
page.getByText(
|
||||
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
@@ -54,7 +54,9 @@ export async function seedMockAgentWorkspace(
|
||||
}
|
||||
|
||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,9 +132,9 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
fetchAgent(
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
|
||||
@@ -393,9 +393,9 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
|
||||
// open) that surfaces the local host by its label. The per-row connection
|
||||
// endpoint only appears on dropdown rows in the multi-host case, which this
|
||||
// fixture does not exercise.
|
||||
// open) that surfaces the local host by its label. The "Local" marker only
|
||||
// appears on dropdown rows in the multi-host case, which this fixture does not
|
||||
// exercise.
|
||||
const picker = sidebar.getByTestId("settings-host-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||
|
||||
@@ -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,270 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection";
|
||||
import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const OFFLINE_SERVER_IDS = [
|
||||
"srv_e2e_preselect_offline_1",
|
||||
"srv_e2e_preselect_offline_2",
|
||||
"srv_e2e_preselect_offline_3",
|
||||
];
|
||||
|
||||
// New Workspace preselection is a form-context decision, not startup routing.
|
||||
// Entry points from a workspace should carry the current project context, and a
|
||||
// plain /new must not let a stale remembered offline host steal the initial host
|
||||
// when there is exactly one online saved host.
|
||||
|
||||
async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+n`);
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
projectDisplayName: string,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toContainText(projectDisplayName, { timeout });
|
||||
}
|
||||
|
||||
async function expectAnyProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toBeVisible({ timeout });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const label = ((await projectPicker.textContent()) ?? "").trim();
|
||||
return label || "Choose project";
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.not.toBe("Choose project");
|
||||
}
|
||||
|
||||
async function openColdRestoredWorkspaceWithOfflineHostFirst(
|
||||
page: import("@playwright/test").Page,
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId, workspaceId }) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId }));
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: connectedServerId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
async function openNewWorkspaceWithStaleOfflineSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(buildNewWorkspaceRoute());
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function seedOfflineHostsWithStaleSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("New workspace preselects the open workspace's project", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
let projectA: SeededWorkspace;
|
||||
let projectB: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
projectA = await seedWorkspace({ repoPrefix: "preselect-a-" });
|
||||
projectB = await seedWorkspace({ repoPrefix: "preselect-b-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await projectA?.cleanup();
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNewWorkspaceWithStaleOfflineSelection(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectAnyProjectPreselectedWithin(page, 8_000);
|
||||
});
|
||||
|
||||
test("stale remembered offline host heals after visiting the connected workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const connectedServerId = getServerId();
|
||||
await seedOfflineHostsWithStaleSelection(page);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -118,7 +118,7 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first", async ({ page }) => {
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.103",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,26 +112,6 @@ function footerOwners(layout: StreamLayout): string[] {
|
||||
return owners;
|
||||
}
|
||||
|
||||
function footerAssistantIds(layout: StreamLayout): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...layout.liveHead.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...layout.history, ...layout.liveHead].flatMap((item) =>
|
||||
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
@@ -309,144 +289,4 @@ describe("layoutStream", () => {
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places inline footer after trailing visible tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([tool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places split live-head tool footer using the assistant from history on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
head: [tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[tool.id]: assistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for footer content while placing after the visible turn end on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const firstTool = toolCall("tool-1", 3);
|
||||
const latestAssistant = assistantMessage("a2", 4);
|
||||
const latestTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
firstTool,
|
||||
latestAssistant,
|
||||
latestTool,
|
||||
userMessage("u2", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, latestAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
|
||||
latestAssistant.id,
|
||||
);
|
||||
expect(footerOwners(layout)).toEqual([latestTool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const secondAssistant = assistantMessage("a2", 4);
|
||||
const secondTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
userMessage("u2", 3),
|
||||
secondAssistant,
|
||||
secondTool,
|
||||
userMessage("u3", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, secondAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
|
||||
firstAssistant.id,
|
||||
);
|
||||
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
|
||||
secondAssistant.id,
|
||||
);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[firstAssistant.id]: firstAssistant.id,
|
||||
[secondTool.id]: secondAssistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"does not render a completed footer before tool rows while the turn is running on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface StreamLayoutInput {
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
@@ -51,14 +52,6 @@ interface LayoutSegmentInput {
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
@@ -75,111 +68,45 @@ function createTurnFooterHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantInTurn(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
;
|
||||
index >= 0 && index < items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!canCrossBoundary ||
|
||||
!input.boundaryAboveItems ||
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
index = input.boundaryAboveIndex;
|
||||
canCrossBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (latestIndex === null) {
|
||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (startIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!assistant) {
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
item,
|
||||
items: footerItems,
|
||||
index: startIndex,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCompletedFooter(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
agentStatus: string;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running"))
|
||||
);
|
||||
}
|
||||
|
||||
function isToolSequenceItem(
|
||||
item: StreamItem | null,
|
||||
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
|
||||
function isToolSequenceItem(item: StreamItem | null): boolean {
|
||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
}
|
||||
|
||||
@@ -247,17 +174,19 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
agentStatus: input.agentStatus,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -298,6 +227,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
const historyCacheKey = [
|
||||
input.agentStatus,
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
@@ -315,6 +245,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
} else {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -322,8 +253,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
boundaryAboveItems: null,
|
||||
boundaryAboveIndex: null,
|
||||
});
|
||||
byKey.set(historyCacheKey, history);
|
||||
}
|
||||
@@ -333,6 +262,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -340,8 +270,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
boundaryAboveItems: input.history,
|
||||
boundaryAboveIndex: historyBoundaryIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -194,9 +194,7 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
expect(scrollContainer).toBeInstanceOf(HTMLElement);
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||
@@ -207,408 +205,4 @@ describe("createWebStreamStrategy", () => {
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [],
|
||||
liveHead: [],
|
||||
},
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
const scrollElement = scrollContainer;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 1400 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollElement.scrollTop).toBe(1400);
|
||||
});
|
||||
|
||||
it("does not force bottom on delayed route readiness after the user scrolls away", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3)],
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,12 +117,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
contentRef.current = node;
|
||||
}, []);
|
||||
const [followOutput, setFollowOutputr] = useState(true);
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
followOutputRef.current = value;
|
||||
setFollowOutputr(value);
|
||||
return value;
|
||||
};
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const lastKnownScrollTopRef = useRef(0);
|
||||
const pendingUserScrollUpIntentRef = useRef(false);
|
||||
const isPointerScrollActiveRef = useRef(false);
|
||||
@@ -146,9 +145,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
followOutputRef.current = followOutput;
|
||||
|
||||
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
|
||||
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
|
||||
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
@@ -278,14 +276,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown) {
|
||||
if (!followOutputRef.current && isAtBottom) {
|
||||
setFollowOutput(true);
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (scrolledUp || !isAtBottom) {
|
||||
if (scrolledUp) {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
}
|
||||
@@ -322,9 +318,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!isActivationReady) {
|
||||
return;
|
||||
}
|
||||
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
setFollowOutput(true);
|
||||
forceStickToBottom();
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -343,13 +336,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [
|
||||
activationKey,
|
||||
forceStickToBottom,
|
||||
hasRouteBottomAnchorRequest,
|
||||
isActivationReady,
|
||||
scheduleStickToBottom,
|
||||
]);
|
||||
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
messageId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAssistantTurnBoundaryMessageId", () => {
|
||||
it("uses the selected assistant message id", () => {
|
||||
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), selected],
|
||||
startIndex: 1,
|
||||
}),
|
||||
).toBe("msg-assistant-1");
|
||||
});
|
||||
|
||||
it("does not borrow a boundary id from another assistant in the same turn", () => {
|
||||
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
const selected = assistantMessage("assistant-2", 3);
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), first, selected],
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires the selected item to be an assistant message", () => {
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
|
||||
startIndex: 0,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
items: readonly StreamItem[];
|
||||
startIndex: number;
|
||||
}): string | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
// Forking without the selected assistant's durable message id would send the wrong slice.
|
||||
return item.messageId || undefined;
|
||||
}
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
import {
|
||||
AssistantTurnFooter,
|
||||
LiveElapsed,
|
||||
STREAM_METADATA_FONT_SIZE,
|
||||
type AssistantForkTarget,
|
||||
} from "@/components/message";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
@@ -28,23 +22,17 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
});
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
@@ -62,7 +50,6 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -72,13 +59,11 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
@@ -87,7 +72,6 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
@@ -127,13 +111,11 @@ function CompletedTurnFooter({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
@@ -144,18 +126,12 @@ function CompletedTurnFooter({
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
|
||||
items,
|
||||
startIndex,
|
||||
});
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
@@ -60,12 +59,7 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
TurnFooter,
|
||||
type AssistantTurnForkHandler,
|
||||
type TurnContentStrategy,
|
||||
} from "./turn-footer";
|
||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
@@ -82,21 +76,11 @@ import {
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -137,7 +121,6 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
return null;
|
||||
@@ -150,7 +133,6 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
@@ -251,56 +233,6 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
draftId: string;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
missingAttachmentMessage: string;
|
||||
}): WorkspaceComposerAttachment {
|
||||
if (!input.payload.attachment) {
|
||||
throw new Error(input.missingAttachmentMessage);
|
||||
}
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: `chat_history:${input.draftId}`,
|
||||
attachment: input.payload.attachment,
|
||||
source: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
itemCount: input.payload.itemCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
|
||||
if (!agent.provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftTabTarget(
|
||||
setup: WorkspaceDraftTabSetup | undefined,
|
||||
draftId: string,
|
||||
): WorkspaceTabTarget {
|
||||
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -317,7 +249,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -342,9 +273,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -433,76 +361,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
agentId,
|
||||
boundaryMessageId ? { boundaryMessageId } : {},
|
||||
);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
payload,
|
||||
missingAttachmentMessage: t("message.actions.forkFailed"),
|
||||
});
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
||||
attachments: [attachment],
|
||||
});
|
||||
return draftId;
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
const draftId = await prepareForkDraft();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: buildForkDraftTabTarget(draftSetup, draftId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
setup: draftSetup,
|
||||
sourceDirectory,
|
||||
});
|
||||
}
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
@@ -744,10 +602,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
[renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -772,11 +629,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -933,60 +788,22 @@ function agentCapabilityFlagsEqual(
|
||||
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
||||
}
|
||||
|
||||
function collectAgentProjectPlacementDiffs(
|
||||
left: AgentScreenAgent["projectPlacement"],
|
||||
right: AgentScreenAgent["projectPlacement"],
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
if (left?.projectName !== right?.projectName) {
|
||||
reasons.push("agent.projectPlacement.projectName");
|
||||
}
|
||||
if (left?.projectKey !== right?.projectKey) {
|
||||
reasons.push("agent.projectPlacement.projectKey");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.provider !== right.provider) reasons.push("agent.provider");
|
||||
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
|
||||
if (left.model !== right.model) reasons.push("agent.model");
|
||||
if (left.thinkingOptionId !== right.thinkingOptionId) {
|
||||
reasons.push("agent.thinkingOptionId");
|
||||
}
|
||||
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
|
||||
reasons.push("agent.runtimeInfo.modeId");
|
||||
}
|
||||
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
|
||||
reasons.push("agent.runtimeInfo.model");
|
||||
}
|
||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||
}
|
||||
if (left.features !== right.features) reasons.push("agent.features");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
||||
if (left.id !== right.id) reasons.push("agent.id");
|
||||
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
|
||||
if (left.status !== right.status) reasons.push("agent.status");
|
||||
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
||||
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
||||
reasons.push("agent.capabilities");
|
||||
}
|
||||
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
||||
reasons.push(...collectAgentSetupDiffs(left, right));
|
||||
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
|
||||
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,7 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -64,7 +58,6 @@ import {
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -885,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
|
||||
@@ -944,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" />
|
||||
@@ -956,23 +944,12 @@ function RootStack() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRouteNavigationBridge() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
return registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent({ agentId })
|
||||
.fetchAgent(agentId)
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
resolveWorkspaceSelectionStatus,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const serverId = useHostRouteServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionWorkspaceId =
|
||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||
|
||||
if (!serverId || !isWorkspaceSelectionLoaded) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
href={resolveHostIndexRoute({
|
||||
serverId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
if (!serverId) return null;
|
||||
// COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
|
||||
return <Redirect href={buildOpenProjectRoute()} />;
|
||||
}
|
||||
|
||||
@@ -2,36 +2,25 @@ 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;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
draftId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
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 draftId = typeof params.draftId === "string" ? params.draftId : undefined;
|
||||
const screenKey = JSON.stringify([
|
||||
serverId,
|
||||
sourceDirectory ?? null,
|
||||
displayName ?? null,
|
||||
projectId ?? null,
|
||||
draftId ?? null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
MousePointer2,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export interface AttachmentPillContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getReviewSubtitle(count: number, t: TFunction): string {
|
||||
return count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count });
|
||||
}
|
||||
|
||||
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
}
|
||||
|
||||
function getTextAttachmentSubtitle(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return "Previous conversation";
|
||||
}
|
||||
return t("message.attachments.text");
|
||||
}
|
||||
|
||||
export function getAgentAttachmentPillContent(
|
||||
attachment: AgentAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
switch (attachment.type) {
|
||||
case "review":
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.comments.length, t),
|
||||
};
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment, t),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceAttachmentPillContent(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title,
|
||||
subtitle: getPullRequestContextSubtitle(attachment),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.commentCount, t),
|
||||
};
|
||||
}
|
||||
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
|
||||
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentBrowserIcon = (
|
||||
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -63,18 +63,6 @@ export type PullRequestContextAttachment =
|
||||
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||
|
||||
export interface ChatHistoryContextAttachment {
|
||||
kind: "chat_history";
|
||||
id: string;
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>;
|
||||
source: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
itemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
@@ -87,7 +75,6 @@ export type WorkspaceComposerAttachment =
|
||||
attachment: BrowserElementAttachment;
|
||||
}
|
||||
| PullRequestContextAttachment
|
||||
| ChatHistoryContextAttachment
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createImageSourceCacheKey,
|
||||
fileUriToPath,
|
||||
localFileSourceToPath,
|
||||
parseDataUrl,
|
||||
parseImageDataUrl,
|
||||
pathToFileUri,
|
||||
} from "./utils";
|
||||
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
|
||||
|
||||
describe("pathToFileUri", () => {
|
||||
it("converts POSIX absolute paths to file URIs", () => {
|
||||
@@ -30,24 +23,6 @@ describe("pathToFileUri", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileUriToPath", () => {
|
||||
it("converts Windows drive-letter file URIs back to paths", () => {
|
||||
expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localFileSourceToPath", () => {
|
||||
it("decodes markdown-encoded Windows drive-letter paths", () => {
|
||||
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
|
||||
});
|
||||
|
||||
it("preserves literal percent sequences in plain local paths", () => {
|
||||
expect(localFileSourceToPath("/tmp/image%20with%20literal%20percent.png")).toBe(
|
||||
"/tmp/image%20with%20literal%20percent.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDataUrl", () => {
|
||||
it("accepts base64 data URLs with media-type parameters", () => {
|
||||
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
|
||||
|
||||
@@ -139,41 +139,11 @@ export function pathToFileUri(path: string): string {
|
||||
return `file:///${path.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
function decodeFilePathSource(source: string): string {
|
||||
try {
|
||||
return decodeURIComponent(source);
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsDrivePath(path: string): string {
|
||||
if (!/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isMarkdownEncodedWindowsDrivePath(source: string): boolean {
|
||||
return /^[A-Za-z]:(?:%5[Cc]|%2[Ff])/.test(source);
|
||||
}
|
||||
|
||||
export function fileUriToPath(uri: string): string {
|
||||
if (!uri.startsWith("file://")) {
|
||||
return uri;
|
||||
}
|
||||
const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, ""));
|
||||
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
|
||||
}
|
||||
|
||||
export function localFileSourceToPath(source: string): string {
|
||||
let path = source;
|
||||
if (source.startsWith("file://")) {
|
||||
path = fileUriToPath(source);
|
||||
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
|
||||
path = decodeFilePathSource(source);
|
||||
}
|
||||
return normalizeWindowsDrivePath(path);
|
||||
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
|
||||
}
|
||||
|
||||
export function getFileExtensionFromName(fileName: string | null | undefined): string {
|
||||
|
||||
@@ -22,7 +22,6 @@ export function isWorkspaceAttachment(
|
||||
return (
|
||||
attachment?.kind === "review" ||
|
||||
attachment?.kind === "browser_element" ||
|
||||
attachment?.kind === "chat_history" ||
|
||||
isPullRequestContextAttachment(attachment)
|
||||
);
|
||||
}
|
||||
@@ -34,7 +33,6 @@ export function userAttachmentsOnly(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" &&
|
||||
attachment.kind !== "browser_element" &&
|
||||
attachment.kind !== "chat_history" &&
|
||||
!isPullRequestContextAttachment(attachment),
|
||||
);
|
||||
}
|
||||
@@ -58,8 +56,5 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
text: attachment.text,
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return attachment.attachment;
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
import {
|
||||
appendWorkspaceAttachment,
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
collectWorkspaceAttachmentsForScopes,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "./workspace-attachments-store";
|
||||
@@ -59,24 +57,6 @@ function contextAttachment(id: string): WorkspaceComposerAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id,
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text,
|
||||
},
|
||||
source: {
|
||||
serverId: "local",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachments store", () => {
|
||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||
expect(
|
||||
@@ -96,12 +76,6 @@ describe("workspace attachments store", () => {
|
||||
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
|
||||
});
|
||||
|
||||
it("scopes draft attachments by draft id", () => {
|
||||
expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe(
|
||||
"workspace-attachments:draft=draft-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes and clears attachments for a workspace scope", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
@@ -142,13 +116,6 @@ describe("workspace attachments store", () => {
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("dedupes repeated chat history attachments by id", () => {
|
||||
const original = chatHistoryAttachment("chat_history:draft-1", "Original chat.");
|
||||
const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat.");
|
||||
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("adds a workspace attachment against the current scope state", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
@@ -171,25 +138,4 @@ describe("workspace attachments store", () => {
|
||||
context,
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects attachments across requested scopes in scope order", () => {
|
||||
const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
|
||||
const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const draftContext = chatHistoryAttachment("chat_history:draft-1");
|
||||
const workspaceContext = contextAttachment("comment-1");
|
||||
|
||||
expect(
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope: {
|
||||
[workspaceScopeKey]: [workspaceContext],
|
||||
[draftScopeKey]: [draftContext],
|
||||
},
|
||||
scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey],
|
||||
}),
|
||||
).toEqual([draftContext, workspaceContext]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
|
||||
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
|
||||
|
||||
export interface WorkspaceAttachmentScopeInput {
|
||||
kind?: "workspace";
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface DraftWorkspaceAttachmentScopeInput {
|
||||
kind: "draft";
|
||||
draftId: string;
|
||||
}
|
||||
|
||||
export type WorkspaceAttachmentScope =
|
||||
| WorkspaceAttachmentScopeInput
|
||||
| DraftWorkspaceAttachmentScopeInput;
|
||||
|
||||
interface WorkspaceAttachmentsStoreState {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
}
|
||||
@@ -63,10 +52,6 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
|
||||
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
|
||||
}
|
||||
|
||||
function areWorkspaceAttachmentsEqual(
|
||||
left: readonly WorkspaceComposerAttachment[],
|
||||
right: readonly WorkspaceComposerAttachment[],
|
||||
@@ -81,12 +66,11 @@ function areWorkspaceAttachmentsEqual(
|
||||
}
|
||||
|
||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||
const isContextAttachment =
|
||||
attachment.kind === "chat_history" ||
|
||||
attachment.kind === "github.pull_request_comment" ||
|
||||
attachment.kind === "github.pull_request_review" ||
|
||||
attachment.kind === "github.pull_request_check";
|
||||
if (!isContextAttachment) {
|
||||
if (
|
||||
attachment.kind !== "github.pull_request_comment" &&
|
||||
attachment.kind !== "github.pull_request_review" &&
|
||||
attachment.kind !== "github.pull_request_check"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
@@ -161,25 +145,11 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
||||
},
|
||||
}));
|
||||
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string {
|
||||
const isDraftScope = input.kind === "draft";
|
||||
const draftId = isDraftScope ? input.draftId : "";
|
||||
const serverId = isDraftScope ? "" : input.serverId;
|
||||
const workspaceId = isDraftScope ? undefined : input.workspaceId;
|
||||
const cwd = isDraftScope ? "" : input.cwd;
|
||||
return useMemo(() => {
|
||||
if (isDraftScope) {
|
||||
return buildDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
}
|
||||
return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
|
||||
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
|
||||
}
|
||||
|
||||
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
|
||||
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const { serverId, workspaceId, cwd } = input;
|
||||
return useMemo(
|
||||
() => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""),
|
||||
[normalizedDraftId],
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
||||
[serverId, workspaceId, cwd],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,43 +159,6 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom
|
||||
);
|
||||
}
|
||||
|
||||
export function collectWorkspaceAttachmentsForScopes(input: {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
scopeKeys: readonly string[];
|
||||
}): readonly WorkspaceComposerAttachment[] {
|
||||
const attachments: WorkspaceComposerAttachment[] = [];
|
||||
for (const scopeKey of input.scopeKeys) {
|
||||
const normalizedScopeKey = scopeKey.trim();
|
||||
if (!normalizedScopeKey) {
|
||||
continue;
|
||||
}
|
||||
attachments.push(
|
||||
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
|
||||
);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export function useWorkspaceAttachmentsForScopes(
|
||||
scopeKeys: readonly string[] | undefined,
|
||||
): readonly WorkspaceComposerAttachment[] {
|
||||
const normalizedScopeKeys = useMemo(
|
||||
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
|
||||
[scopeKeys],
|
||||
);
|
||||
const attachmentsByScope = useWorkspaceAttachmentsStore(
|
||||
useShallow((state) => state.attachmentsByScope),
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope,
|
||||
scopeKeys: normalizedScopeKeys,
|
||||
}),
|
||||
[attachmentsByScope, normalizedScopeKeys],
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkspaceAttachmentsStore(): void {
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
@@ -449,6 +451,9 @@ export interface AdaptiveModalSheetProps {
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
desktopMaxWidth?: number;
|
||||
/** When provided, wraps the card content in a FileDropZone. */
|
||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
}
|
||||
@@ -462,6 +467,8 @@ export function AdaptiveModalSheet({
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
@@ -595,7 +602,18 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>{cardInner}</View>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone
|
||||
onFilesDropped={onFilesDropped}
|
||||
onGenericFilesDropped={onGenericFilesDropped}
|
||||
>
|
||||
{cardInner}
|
||||
</FileDropZone>
|
||||
) : (
|
||||
cardInner
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Split } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export type AssistantForkTarget = "tab" | "workspace";
|
||||
|
||||
interface AssistantForkMenuProps {
|
||||
onFork: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const ThemedSplit = withUnistyles(Split);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export const AssistantForkMenu = memo(function AssistantForkMenu({
|
||||
onFork,
|
||||
testID = "assistant-fork-menu",
|
||||
}: AssistantForkMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingTarget, setPendingTarget] = useState<AssistantForkTarget | null>(null);
|
||||
const isLocked = pendingTarget !== null;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next && pendingTarget !== null) return;
|
||||
setIsOpen(next);
|
||||
},
|
||||
[pendingTarget],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(target: AssistantForkTarget) => async () => {
|
||||
if (isLocked) return;
|
||||
setPendingTarget(target);
|
||||
try {
|
||||
await onFork(target);
|
||||
} finally {
|
||||
setPendingTarget(null);
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[isLocked, onFork],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
|
||||
[isLocked],
|
||||
);
|
||||
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("message.actions.forkMenu")}</Text>
|
||||
</TooltipContent>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
const forkIcon = useMemo(() => <ThemedSplit size={16} uniProps={foregroundColorMapping} />, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<View style={styles.triggerSlot} collapsable={false}>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel={t("message.actions.forkMenu")}
|
||||
accessibilityRole="button"
|
||||
disabled={isLocked}
|
||||
style={triggerStyle}
|
||||
testID={`${testID}-trigger`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<ThemedSplit
|
||||
size={16}
|
||||
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent}
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" minWidth={220} side="bottom" testID={`${testID}-content`}>
|
||||
<DropdownMenuItem
|
||||
closeOnSelect={false}
|
||||
disabled={isLocked && pendingTarget !== "tab"}
|
||||
leading={forkIcon}
|
||||
onSelect={handleSelect("tab")}
|
||||
status={pendingTarget === "tab" ? "pending" : undefined}
|
||||
testID={`${testID}-new-tab`}
|
||||
>
|
||||
{t("message.actions.forkInNewTab")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnSelect={false}
|
||||
disabled={isLocked && pendingTarget !== "workspace"}
|
||||
leading={forkIcon}
|
||||
onSelect={handleSelect("workspace")}
|
||||
status={pendingTarget === "workspace" ? "pending" : undefined}
|
||||
testID={`${testID}-new-workspace`}
|
||||
>
|
||||
{t("message.actions.forkInNewWorkspace")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
triggerSlot: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
@@ -13,7 +13,6 @@ import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
@@ -200,10 +199,9 @@ function CommandCenterAgentRow({
|
||||
|
||||
interface CommandCenterAgentRowContentProps {
|
||||
agent: AggregatedAgent;
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
@@ -215,7 +213,7 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
@@ -228,8 +226,7 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{showHost ? `${agent.serverLabel} · ` : ""}
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -249,7 +246,6 @@ interface AgentItemsSectionProps {
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function AgentItemsSection({
|
||||
@@ -261,7 +257,6 @@ function AgentItemsSection({
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
showHost,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -282,7 +277,7 @@ function AgentItemsSection({
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
</CommandCenterAgentRow>
|
||||
);
|
||||
})}
|
||||
@@ -307,8 +302,6 @@ export function CommandCenter() {
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
// Host names only earn their space once results can span more than one host.
|
||||
const showHost = useHosts().length > 1;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
@@ -484,7 +477,6 @@ export function CommandCenter() {
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
showHost={showHost}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
102
packages/app/src/components/file-drop-zone.tsx
Normal file
102
packages/app/src/components/file-drop-zone.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: React.ReactNode;
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: DroppedItem[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({
|
||||
children,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled = false,
|
||||
}: FileDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const overlayOpacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
overlayOpacity.value = withTiming(isDragging ? 1 : 0, { duration: 150 });
|
||||
}, [isDragging, overlayOpacity]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: overlayOpacity.value,
|
||||
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
const overlayStyle = useMemo(
|
||||
() => [styles.overlay, overlayAnimatedStyle],
|
||||
[overlayAnimatedStyle],
|
||||
);
|
||||
|
||||
// On non-web platforms, just render children
|
||||
if (!IS_WEB) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
// Cast ref for web - View renders as div on web
|
||||
ref={containerRef as unknown as React.RefObject<View>}
|
||||
style={styles.container}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Drop overlay */}
|
||||
<Animated.View style={overlayStyle}>
|
||||
{/* Backdrop */}
|
||||
<View style={styles.backdrop} />
|
||||
{/* Content */}
|
||||
<View style={styles.overlayContent}>
|
||||
<Upload size={32} color={theme.colors.primary} />
|
||||
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
overlayContent: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
overlayText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import type { FileDropSink } from "./types";
|
||||
|
||||
export interface FileDropContextValue {
|
||||
/** Drag-active flag, driven on the UI thread so toggling it triggers no React render. */
|
||||
isDragging: SharedValue<boolean>;
|
||||
/** Active sink can't accept right now (e.g. composer submitting): hide backdrop and reject drops. */
|
||||
suppressed: SharedValue<boolean>;
|
||||
/** Whether a consumer is currently registered — no consumer (e.g. archived agent), no backdrop. */
|
||||
hasSink: SharedValue<boolean>;
|
||||
/** Register the active sink. Pass a getter so the zone always reads the latest handlers. */
|
||||
registerSink: (getSink: () => FileDropSink | null) => () => void;
|
||||
}
|
||||
|
||||
export const FileDropContext = createContext<FileDropContextValue | null>(null);
|
||||
|
||||
export function useFileDropContext(): FileDropContextValue | null {
|
||||
return useContext(FileDropContext);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { View, Text, StyleSheet as RNStyleSheet } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useFileDropContext } from "./context";
|
||||
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const primaryIconColorMapping = (theme: Theme) => ({ color: theme.colors.primary });
|
||||
|
||||
/**
|
||||
* Drop overlay rendered by FileDropZone. Reads `isDragging` on the UI thread so the dim
|
||||
* only ever repaints the backdrop — never the surrounding tree.
|
||||
*/
|
||||
export function FileDropBackdrop() {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useFileDropContext();
|
||||
const isDragging = ctx?.isDragging;
|
||||
const suppressed = ctx?.suppressed;
|
||||
const hasSink = ctx?.hasSink;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const active = isDragging?.value && hasSink?.value && !suppressed?.value;
|
||||
return { opacity: withTiming(active ? 1 : 0, { duration: 150 }) };
|
||||
});
|
||||
|
||||
const overlayStyle = useMemo(() => [positionStyles.overlay, animatedStyle], [animatedStyle]);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
// Animated.View keeps only plain-RN positioning; theme-dependent paint lives on the
|
||||
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
|
||||
// on theme change — see docs/unistyles.md).
|
||||
return (
|
||||
<Animated.View style={overlayStyle} pointerEvents="none">
|
||||
<View style={styles.backdrop} />
|
||||
<View style={styles.content}>
|
||||
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
|
||||
<Text style={styles.text}>{t("composer.attachments.dropFilesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
overlay: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
content: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { FileDropContext, type FileDropContextValue } from "./context";
|
||||
import { FileDropBackdrop } from "./file-drop-backdrop";
|
||||
import { useDropListeners } from "./use-drop-listeners";
|
||||
import type { FileDropSink } from "./types";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: ReactNode;
|
||||
/** When true, no drops are accepted and the backdrop stays hidden. */
|
||||
disabled?: boolean;
|
||||
/** Styles the drop area (defaults to filling its parent). The backdrop fills this area. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a drag-and-drop area and renders its dim backdrop. Files are consumed by any
|
||||
* descendant calling `useFileDrop` — the drop area, the backdrop, and the consumer are
|
||||
* decoupled, so a consumer's layout can never collapse the backdrop.
|
||||
*/
|
||||
export function FileDropZone({ children, disabled = false, style }: FileDropZoneProps) {
|
||||
const isDragging = useSharedValue(false);
|
||||
const suppressed = useSharedValue(false);
|
||||
const hasSink = useSharedValue(false);
|
||||
const activeGetSink = useRef<(() => FileDropSink | null) | null>(null);
|
||||
|
||||
const registerSink = useCallback(
|
||||
(getSink: () => FileDropSink | null) => {
|
||||
activeGetSink.current = getSink;
|
||||
hasSink.value = true;
|
||||
return () => {
|
||||
if (activeGetSink.current === getSink) {
|
||||
activeGetSink.current = null;
|
||||
hasSink.value = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
[hasSink],
|
||||
);
|
||||
|
||||
const getSink = useCallback(() => activeGetSink.current?.() ?? null, []);
|
||||
|
||||
const ctx = useMemo<FileDropContextValue>(
|
||||
() => ({ isDragging, suppressed, hasSink, registerSink }),
|
||||
[isDragging, suppressed, hasSink, registerSink],
|
||||
);
|
||||
|
||||
const containerRef = useDropListeners({ isDragging, suppressed, hasSink, getSink, disabled });
|
||||
|
||||
const targetStyle = useMemo(() => [styles.target, style], [style]);
|
||||
|
||||
// On native there is no web drag-and-drop, so skip the listeners and the backdrop — but still
|
||||
// render the styled layout View (callers use FileDropZone as their container) and provide
|
||||
// context so useFileDrop no-ops safely.
|
||||
if (!isWeb) {
|
||||
return (
|
||||
<FileDropContext.Provider value={ctx}>
|
||||
<View style={targetStyle}>{children}</View>
|
||||
</FileDropContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileDropContext.Provider value={ctx}>
|
||||
<View ref={containerRef as unknown as RefObject<View>} style={targetStyle}>
|
||||
{children}
|
||||
<FileDropBackdrop />
|
||||
</View>
|
||||
</FileDropContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// No default flex: the caller's `style` owns sizing (full-area surfaces pass flex:1; a dialog
|
||||
// passes a content-sized style). `position` anchors the absolutely-positioned backdrop.
|
||||
const styles = StyleSheet.create({
|
||||
target: {
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
file: File;
|
||||
}
|
||||
export interface DroppedPathItem {
|
||||
kind: "desktop-path";
|
||||
path: string;
|
||||
}
|
||||
export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
|
||||
/**
|
||||
* What a consumer (e.g. a composer) registers to receive files dropped onto the
|
||||
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
|
||||
* everything else arrives raw via `onGenericFiles`.
|
||||
*/
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFileDropContext } from "./context";
|
||||
import type { FileDropSink } from "./types";
|
||||
|
||||
interface UseFileDropOptions {
|
||||
/** When true, the zone hides the backdrop and rejects drops atomically (e.g. while submitting). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive files dropped onto the surrounding FileDropZone. The sink is read through
|
||||
* a ref, so passing a fresh object every render neither re-registers nor re-renders.
|
||||
* No-ops when rendered without a FileDropZone ancestor.
|
||||
*/
|
||||
export function useFileDrop(sink: FileDropSink, options?: UseFileDropOptions): void {
|
||||
const ctx = useFileDropContext();
|
||||
const sinkRef = useRef(sink);
|
||||
sinkRef.current = sink;
|
||||
const disabled = options?.disabled ?? false;
|
||||
|
||||
const registerSink = ctx?.registerSink;
|
||||
useEffect(() => {
|
||||
if (!registerSink) return;
|
||||
return registerSink(() => sinkRef.current);
|
||||
}, [registerSink]);
|
||||
|
||||
const suppressed = ctx?.suppressed;
|
||||
useEffect(() => {
|
||||
if (!suppressed) return;
|
||||
suppressed.value = disabled;
|
||||
return () => {
|
||||
suppressed.value = false;
|
||||
};
|
||||
}, [suppressed, disabled]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { GestureResponderEvent } from "react-native";
|
||||
import { Plus, Server, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
@@ -31,48 +30,30 @@ export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElem
|
||||
);
|
||||
}
|
||||
|
||||
// Standard secure/plain web ports carry no information in the host display, so
|
||||
// "relay.paseo.sh:443" reads as "relay.paseo.sh" while "127.0.0.1:6767" is kept.
|
||||
function formatConnectionEndpoint(endpoint: string): string {
|
||||
return endpoint.replace(/:(?:443|80)$/, "");
|
||||
}
|
||||
|
||||
// Socket/pipe transports have no host:port — their endpoint is a filesystem
|
||||
// path, so they read as "Local". TCP and relay show the address being used.
|
||||
function formatActiveConnectionLabel(connection: ActiveConnection): string {
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return "Local";
|
||||
}
|
||||
return formatConnectionEndpoint(connection.endpoint);
|
||||
}
|
||||
|
||||
export interface HostPickerOptionProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
showActiveConnection: boolean;
|
||||
isLocal: boolean;
|
||||
selected?: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
localMarkerTestID?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function HostPickerOption({
|
||||
serverId,
|
||||
label,
|
||||
showActiveConnection,
|
||||
isLocal,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
onOpenHostSettings,
|
||||
localMarkerTestID,
|
||||
testID,
|
||||
}: HostPickerOptionProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||
const connectionLabel =
|
||||
showActiveConnection && activeConnection
|
||||
? formatActiveConnectionLabel(activeConnection)
|
||||
: undefined;
|
||||
const leadingSlot = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
|
||||
const handleSettingsPress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
@@ -82,20 +63,31 @@ export function HostPickerOption({
|
||||
[onOpenHostSettings, serverId],
|
||||
);
|
||||
const trailingSlot = useMemo(() => {
|
||||
if (!onOpenHostSettings) return undefined;
|
||||
if (!isLocal && !onOpenHostSettings) return undefined;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<>
|
||||
{isLocal ? (
|
||||
<Text style={styles.localMarker} testID={localMarkerTestID}>
|
||||
Local
|
||||
</Text>
|
||||
) : null}
|
||||
{onOpenHostSettings ? (
|
||||
<Pressable
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
handleSettingsPress,
|
||||
isLocal,
|
||||
label,
|
||||
localMarkerTestID,
|
||||
onOpenHostSettings,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
@@ -104,11 +96,11 @@ export function HostPickerOption({
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
interactiveFeedback={false}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
@@ -142,6 +134,7 @@ function SystemHostPickerOption({
|
||||
leadingSlot={leadingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
interactiveFeedback={false}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
@@ -158,7 +151,7 @@ export interface HostPickerProps {
|
||||
includeAllHost?: boolean;
|
||||
includeAddHost?: boolean;
|
||||
onAddHost?: () => void;
|
||||
showActiveConnection?: boolean;
|
||||
showLocalMarker?: boolean;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
searchable?: boolean;
|
||||
title?: string;
|
||||
@@ -166,6 +159,7 @@ export interface HostPickerProps {
|
||||
desktopMinWidth?: number;
|
||||
addHostTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
hostLocalMarkerTestID?: (serverId: string) => string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -179,7 +173,7 @@ export function HostPicker({
|
||||
includeAllHost,
|
||||
includeAddHost,
|
||||
onAddHost,
|
||||
showActiveConnection,
|
||||
showLocalMarker,
|
||||
onOpenHostSettings,
|
||||
searchable,
|
||||
title,
|
||||
@@ -187,6 +181,7 @@ export function HostPicker({
|
||||
desktopMinWidth,
|
||||
addHostTestID,
|
||||
hostOptionTestID,
|
||||
hostLocalMarkerTestID,
|
||||
children,
|
||||
}: HostPickerProps): ReactElement {
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
@@ -250,20 +245,23 @@ export function HostPicker({
|
||||
<HostPickerOption
|
||||
serverId={option.id}
|
||||
label={option.label}
|
||||
showActiveConnection={showActiveConnection === true}
|
||||
isLocal={showLocalMarker === true && localServerId === option.id}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
|
||||
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
|
||||
testID={hostOptionTestID?.(option.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
addHostTestID,
|
||||
hostLocalMarkerTestID,
|
||||
hostOptionTestID,
|
||||
localServerId,
|
||||
onOpenHostSettings,
|
||||
showActiveConnection,
|
||||
showLocalMarker,
|
||||
handleOpenHostSettings,
|
||||
],
|
||||
);
|
||||
@@ -296,4 +294,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
localMarker: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface MiniMaxIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function MiniMaxIcon({ size = 16, color = "currentColor" }: MiniMaxIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color} fillRule="evenodd">
|
||||
<Path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -30,10 +30,9 @@ 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 { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
@@ -43,8 +42,6 @@ import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-worksp
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
@@ -55,7 +52,7 @@ import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
@@ -88,6 +85,7 @@ interface SidebarSharedProps {
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
handleRefresh: () => void;
|
||||
handleNewWorkspaceNavigate: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
@@ -168,6 +166,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker();
|
||||
const chooseHost = useHostChooser();
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
@@ -178,6 +177,15 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleNewWorkspaceNavigate = useCallback(() => {
|
||||
chooseHost({
|
||||
title: "Choose host",
|
||||
onChooseHost: (serverId) => {
|
||||
router.push(buildHostNewWorkspaceRoute(serverId));
|
||||
},
|
||||
});
|
||||
}, [chooseHost]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
@@ -261,6 +269,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
@@ -276,6 +285,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
@@ -290,6 +300,10 @@ function sidebarHostOptionTestID(serverId: string): string {
|
||||
return `sidebar-host-row-${serverId}`;
|
||||
}
|
||||
|
||||
function sidebarHostLocalMarkerTestID(serverId: string): string {
|
||||
return `sidebar-host-local-marker-${serverId}`;
|
||||
}
|
||||
|
||||
function FooterIconButton({
|
||||
buttonRef,
|
||||
onPress,
|
||||
@@ -361,12 +375,13 @@ function SidebarHostPicker({
|
||||
anchorRef={triggerRef}
|
||||
includeAddHost
|
||||
onAddHost={onAddHost}
|
||||
showActiveConnection
|
||||
showLocalMarker
|
||||
onOpenHostSettings={onOpenHostSettings}
|
||||
searchable
|
||||
desktopMinWidth={240}
|
||||
addHostTestID="sidebar-host-add"
|
||||
hostOptionTestID={sidebarHostOptionTestID}
|
||||
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
|
||||
>
|
||||
<FooterIconButton
|
||||
buttonRef={triggerRef}
|
||||
@@ -411,61 +426,6 @@ function HeaderIconTooltipContent({
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
|
||||
label,
|
||||
testID,
|
||||
variant,
|
||||
shortcutKeys,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
label: string;
|
||||
testID: string;
|
||||
variant: "header" | "compact";
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
|
||||
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
|
||||
const supportsWorkspaceMultiplicity = useHostFeature(
|
||||
activeWorkspaceServerId,
|
||||
"workspaceMultiplicity",
|
||||
);
|
||||
const canUseActiveWorkspaceContext = Boolean(
|
||||
activeWorkspace &&
|
||||
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
router.push(
|
||||
activeWorkspaceServerId
|
||||
? buildNewWorkspaceRoute(
|
||||
activeWorkspace && canUseActiveWorkspaceContext
|
||||
? {
|
||||
serverId: activeWorkspaceServerId,
|
||||
sourceDirectory: activeWorkspace.projectRootPath,
|
||||
projectId: activeWorkspace.projectId,
|
||||
}
|
||||
: { serverId: activeWorkspaceServerId },
|
||||
)
|
||||
: buildNewWorkspaceRoute(),
|
||||
);
|
||||
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={Plus}
|
||||
label={label}
|
||||
onPress={handlePress}
|
||||
testID={testID}
|
||||
variant={variant}
|
||||
shortcutKeys={shortcutKeys}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
@@ -546,6 +506,7 @@ function MobileSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
newWorkspaceKeys,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
@@ -591,6 +552,11 @@ function MobileSidebar({
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const handleNewWorkspace = useCallback(() => {
|
||||
closeSidebar();
|
||||
handleNewWorkspaceNavigate();
|
||||
}, [closeSidebar, handleNewWorkspaceNavigate]);
|
||||
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
@@ -732,12 +698,13 @@ function MobileSidebar({
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
<SidebarHeaderRow
|
||||
icon={Plus}
|
||||
label={labels.newWorkspace}
|
||||
onPress={handleNewWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
@@ -818,6 +785,7 @@ function DesktopSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
newWorkspaceKeys,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
@@ -895,8 +863,10 @@ function DesktopSidebar({
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
<SidebarHeaderRow
|
||||
icon={Plus}
|
||||
label={labels.newWorkspace}
|
||||
onPress={handleNewWorkspaceNavigate}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
CircleDot,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@@ -41,13 +42,15 @@ import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Copy,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -86,7 +89,6 @@ import {
|
||||
getFileNameFromPath,
|
||||
parseImageDataUrl,
|
||||
} from "@/attachments/utils";
|
||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
@@ -102,6 +104,7 @@ import {
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import {
|
||||
AttachmentFrame,
|
||||
AttachmentLabel,
|
||||
@@ -113,9 +116,7 @@ import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
@@ -169,6 +170,10 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -397,6 +402,19 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
interface UserMessageImagePillProps {
|
||||
image: UserMessageImageAttachment;
|
||||
onOpen: (image: UserMessageImageAttachment) => void;
|
||||
@@ -414,6 +432,55 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
||||
);
|
||||
}
|
||||
|
||||
interface UserMessageAttachmentContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getUserMessageAttachmentContent(
|
||||
attachment: AgentAttachment,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): UserMessageAttachmentContent {
|
||||
switch (attachment.type) {
|
||||
case "review": {
|
||||
const count = attachment.comments.length;
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle:
|
||||
count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count }),
|
||||
};
|
||||
}
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: t("message.attachments.text"),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -512,7 +579,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
const content = getUserMessageAttachmentContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
@@ -561,11 +628,6 @@ interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
forkBoundaryMessageId?: string;
|
||||
onFork?: (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -610,8 +672,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
completedAt,
|
||||
durationMs,
|
||||
forkBoundaryMessageId,
|
||||
onFork,
|
||||
}: AssistantTurnFooterProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [pressedReveal, setPressedReveal] = useState(false);
|
||||
@@ -651,13 +711,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
revealTimerRef.current = null;
|
||||
}, TIMESTAMP_REVEAL_MS);
|
||||
}, [canSwap]);
|
||||
const handleFork = useCallback(
|
||||
(target: AssistantForkTarget) => {
|
||||
return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId });
|
||||
},
|
||||
[forkBoundaryMessageId, onFork],
|
||||
);
|
||||
const canFork = Boolean(onFork && forkBoundaryMessageId);
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
@@ -665,7 +718,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{durationLabel ? (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -59,12 +59,11 @@ 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";
|
||||
import {
|
||||
shouldShowSidebarHostLabels,
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
@@ -993,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,
|
||||
@@ -1290,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,
|
||||
@@ -1749,14 +1744,13 @@ function WorkspaceRowItem({
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: WorkspaceRowItemProps) {
|
||||
const currentPathname = usePathname();
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
@@ -1881,7 +1875,6 @@ function ProjectBlock({
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
@@ -1902,7 +1895,6 @@ function ProjectBlock({
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
}) {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
@@ -1932,7 +1924,9 @@ function ProjectBlock({
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={item}
|
||||
subtitle={
|
||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
||||
project.hosts.length > 1
|
||||
? (hostLabelByServerId.get(item.serverId) ?? item.serverId)
|
||||
: null
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
@@ -1949,7 +1943,7 @@ function ProjectBlock({
|
||||
},
|
||||
[
|
||||
project.projectKind,
|
||||
showHostLabels,
|
||||
project.hosts.length,
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
hostLabelByServerId,
|
||||
@@ -2117,7 +2111,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.showShortcutBadges === next.showShortcutBadges &&
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||
previous.showHostLabels === next.showHostLabels &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
@@ -2184,7 +2177,6 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
return labels;
|
||||
}, [hosts]);
|
||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||
|
||||
const content =
|
||||
groupMode === "status" ? (
|
||||
@@ -2193,8 +2185,6 @@ export function SidebarWorkspaceList({
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -2208,7 +2198,6 @@ export function SidebarWorkspaceList({
|
||||
parentGestureRef={parentGestureRef}
|
||||
pathname={pathname}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2220,15 +2209,11 @@ function SidebarStatusModeWrapper({
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: {
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
|
||||
@@ -2239,8 +2224,6 @@ function SidebarStatusModeWrapper({
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2256,14 +2239,12 @@ function ProjectModeList({
|
||||
parentGestureRef,
|
||||
pathname,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
> & {
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
@@ -2451,7 +2432,6 @@ function ProjectModeList({
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -2461,7 +2441,6 @@ function ProjectModeList({
|
||||
handleWorktreeCreated,
|
||||
handleWorkspaceReorder,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
@@ -2752,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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -36,10 +36,9 @@ interface DisplayPreferenceOption<Value extends string> {
|
||||
|
||||
export function SidebarDisplayPreferencesMenu() {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
|
||||
const hosts = useHosts();
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
@@ -53,6 +52,13 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
[setGroupMode],
|
||||
);
|
||||
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string | null) => {
|
||||
setHostFilter(serverId);
|
||||
},
|
||||
[setHostFilter],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
@@ -69,7 +75,6 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
const allHostsSelected = hostFilters.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -100,21 +105,20 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||
</View>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-host-filter-all"
|
||||
selected={allHostsSelected}
|
||||
closeOnSelect={false}
|
||||
onSelect={clearHostFilters}
|
||||
>
|
||||
All hosts
|
||||
</DropdownMenuItem>
|
||||
<HostFilterItem
|
||||
label="All hosts"
|
||||
value={null}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
serverId={host.serverId}
|
||||
selected={hostFilters.includes(host.serverId)}
|
||||
onToggle={toggleHostFilter}
|
||||
value={host.serverId}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -163,32 +167,25 @@ function DisplayPreferenceMenuItem<Value extends string>({
|
||||
function HostFilterItem({
|
||||
label,
|
||||
serverId,
|
||||
selected,
|
||||
onToggle,
|
||||
value,
|
||||
hostFilter,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
serverId: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
serverId?: string;
|
||||
value: string | null;
|
||||
hostFilter: string | null;
|
||||
onSelect: (serverId: string | null) => void;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
const isSelected = hostFilter === value;
|
||||
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
|
||||
const status = useHostRuntimeSnapshot(serverId ?? "");
|
||||
const subtitle = serverId
|
||||
? formatConnectionStatus(status?.connectionStatus ?? "idle")
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
@@ -96,8 +95,6 @@ interface StatusWorkspaceListProps {
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
@@ -106,8 +103,6 @@ export function SidebarStatusWorkspaceList({
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const groups = useMemo(
|
||||
() => buildStatusGroups(workspaces, projectNamesByKey),
|
||||
@@ -143,8 +138,6 @@ export function SidebarStatusWorkspaceList({
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
@@ -161,8 +154,6 @@ export function SidebarStatusWorkspaceList({
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
@@ -177,8 +168,6 @@ function StatusGroupList({
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: {
|
||||
groups: StatusGroup[];
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
@@ -186,8 +175,6 @@ function StatusGroupList({
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -203,12 +190,7 @@ function StatusGroupList({
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -222,21 +204,6 @@ function StatusGroupList({
|
||||
);
|
||||
}
|
||||
|
||||
// Status mode breaks the project grouping, so the row needs the project name to stay
|
||||
// legible; the host is appended after a middle dot once labels are active.
|
||||
function buildStatusRowSubtitle({
|
||||
projectName,
|
||||
hostLabel,
|
||||
}: {
|
||||
projectName: string;
|
||||
hostLabel: string | null;
|
||||
}): string {
|
||||
if (!hostLabel) {
|
||||
return projectName;
|
||||
}
|
||||
return projectName ? `${projectName} · ${hostLabel}` : hostLabel;
|
||||
}
|
||||
|
||||
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
|
||||
@@ -321,20 +288,19 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
||||
|
||||
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
workspace,
|
||||
subtitle,
|
||||
projectName,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarStatusWorkspacePlacement;
|
||||
subtitle: string;
|
||||
projectName: string;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const currentPathname = usePathname();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||
@@ -342,15 +308,15 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
if (!workspaceEntry) return null;
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
workspace={workspaceEntry}
|
||||
subtitle={subtitle}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -361,14 +327,14 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
|
||||
function StatusWorkspaceRowWithMenu({
|
||||
workspace,
|
||||
subtitle,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -489,7 +455,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -521,7 +487,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
|
||||
function StatusWorkspaceRowInner({
|
||||
workspace,
|
||||
subtitle,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -538,7 +504,7 @@ function StatusWorkspaceRowInner({
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -588,7 +554,7 @@ function StatusWorkspaceRowInner({
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
subtitle={projectName}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
@@ -860,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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
View,
|
||||
@@ -28,15 +28,10 @@ interface ComboboxTriggerProps extends Omit<PressableProps, "style" | "children"
|
||||
style?: TriggerStyleProp;
|
||||
children?: ReactNode;
|
||||
chevron?: ReactNode | null;
|
||||
// Fill the Pressable's width and use the standard sidebar-row gap, so the
|
||||
// trigger reads as a full-width row: the label expands and the chevron pins to
|
||||
// the trailing edge. Default (false) keeps the content-width pill used by the
|
||||
// composer triggers.
|
||||
block?: boolean;
|
||||
}
|
||||
|
||||
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
|
||||
{ children, chevron, style, block = false, ...props },
|
||||
{ children, chevron, style, ...props },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const pressableStyle = useCallback(
|
||||
@@ -49,11 +44,9 @@ export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function C
|
||||
[style],
|
||||
);
|
||||
|
||||
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
|
||||
|
||||
return (
|
||||
<Pressable ref={ref} collapsable={false} style={pressableStyle} {...props}>
|
||||
<View style={rowStyle}>
|
||||
<View style={styles.row}>
|
||||
{children}
|
||||
{chevron !== null &&
|
||||
(chevron ?? (
|
||||
@@ -68,19 +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],
|
||||
},
|
||||
rowBlock: {
|
||||
flexGrow: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
chevronContainer: {
|
||||
flexShrink: 0,
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user