mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
v0.1.83
...
refactor-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c46e5f8b | ||
|
|
10fdc4330d |
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release beta
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: release-stable
|
||||
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/release-beta
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/release-stable
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -5,18 +5,8 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
# postinstall download from NuGet is large enough to make npm ci flaky.
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -104,8 +94,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -118,7 +108,6 @@ jobs:
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
desktop-tests:
|
||||
strategy:
|
||||
|
||||
4
.github/workflows/desktop-release.yml
vendored
4
.github/workflows/desktop-release.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
rollout_hours:
|
||||
description: "Linear rollout duration in hours. Use 0 for instant rollout."
|
||||
required: false
|
||||
default: "36"
|
||||
default: "24"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
@@ -51,7 +51,7 @@ env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
|
||||
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
|
||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||
|
||||
jobs:
|
||||
|
||||
72
.github/workflows/nix-build.yml
vendored
Normal file
72
.github/workflows/nix-build.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates (main push only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
60
.github/workflows/nix-update-hash.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Nix Update Hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-update-hash.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
||||
git config user.name "paseo-ai[bot]"
|
||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/npm-deps.hash
|
||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
||||
git push
|
||||
99
.github/workflows/nix.yml
vendored
99
.github/workflows/nix.yml
vendored
@@ -1,99 +0,0 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Smoke Nix daemon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
kill "$DAEMON_PID"
|
||||
wait "$DAEMON_PID" || true
|
||||
fi
|
||||
rm -rf "$PASEO_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
echo "Nix daemon exited before becoming reachable."
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "::group::daemon.log"
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo daemon status"
|
||||
./result/bin/paseo daemon status || true
|
||||
echo "::endgroup::"
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Build Nix desktop package
|
||||
run: nix build .#desktop -o result-desktop
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,7 +6,6 @@ build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
result
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
1469
CHANGELOG.md
1469
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
46
CLAUDE.md
46
CLAUDE.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||
|
||||
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
|
||||
**Supported agents:** Claude Code, Codex, and OpenCode.
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -21,29 +21,26 @@ This is an npm workspace monorepo:
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [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/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 |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [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/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [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 |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [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/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -89,7 +86,6 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
|
||||
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
|
||||
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
|
||||
|
||||
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
|
||||
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
## How this project works
|
||||
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
|
||||
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
|
||||
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
|
||||
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
|
||||
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
## Reporting bugs
|
||||
|
||||
Follow these rules if you want your PR to be merged:
|
||||
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
|
||||
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
- Explain the problem you're solving
|
||||
- Include repro steps if it's a bug
|
||||
- Include QA/testing evidence
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
|
||||
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
|
||||
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
|
||||
- **One bug per issue.** Three findings, three issues.
|
||||
|
||||
Your PR will be closed if you do any of these:
|
||||
## Before you start
|
||||
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
- [README.md](README.md)
|
||||
- [docs/architecture.md](docs/architecture.md)
|
||||
- [docs/development.md](docs/development.md)
|
||||
- [docs/coding-standards.md](docs/coding-standards.md)
|
||||
- [docs/testing.md](docs/testing.md)
|
||||
- [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
## AI assistance
|
||||
## What is most helpful
|
||||
|
||||
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.
|
||||
- bug fixes (especially Windows and Linux)
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging and platform fixes
|
||||
- focused UX improvements that fit the product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
npm run dev # daemon + expo
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
```
|
||||
|
||||
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One focused change per PR. Split unrelated cleanups out.
|
||||
- Reference the issue you're fixing, unless it's a small objective bug.
|
||||
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
|
||||
- `npm run typecheck` and `npm run lint` must pass.
|
||||
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
|
||||
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
|
||||
|
||||
**On AI-assisted PRs.** 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 usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -34,7 +34,7 @@
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
|
||||
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
@@ -49,9 +49,7 @@ You need at least one agent CLI installed and configured with your credentials:
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
### Desktop app (recommended)
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ Both look the same in storage. This is an accepted limitation — see [Limitatio
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
|
||||
|
||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||
|
||||
1. Snapshot the current session into the registry
|
||||
@@ -56,7 +54,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
@@ -27,21 +27,17 @@ Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
```bash
|
||||
@@ -52,28 +48,24 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# Recent builds
|
||||
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
|
||||
# List recent workflow runs
|
||||
npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
|
||||
# which has a Submissions section showing the auto-submit to the Play Store).
|
||||
npx eas build:view <build-id>
|
||||
# Inspect a run
|
||||
npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for a failed job
|
||||
npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
|
||||
The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.
|
||||
|
||||
See [docs/release.md](release.md) for the full mobile-build babysitting flow.
|
||||
|
||||
@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
|
||||
│ (Node.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┬────────────┬────────────┐
|
||||
│ │ │ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
|
||||
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi │
|
||||
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ ACP │ │ │ │ │
|
||||
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ Claude │ │ Codex │ │ OpenCode │
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ │
|
||||
└───────────┘ └────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Components at a glance
|
||||
@@ -79,7 +79,6 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
@@ -108,7 +107,7 @@ Enables remote access when the daemon is behind a firewall.
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
@@ -147,10 +146,6 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
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.
|
||||
|
||||
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:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
@@ -210,7 +205,6 @@ initializing → idle ⇄ running
|
||||
|
||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
|
||||
@@ -218,17 +212,16 @@ initializing → idle ⇄ running
|
||||
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| Copilot | GitHub Copilot via ACP | Provider-managed |
|
||||
| OpenCode | OpenCode server / CLI | Provider-managed |
|
||||
| Cursor | ACP wrapper (`acp-agent`) | Provider-managed |
|
||||
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
|
||||
| Generic ACP | ACP wrapper | Provider-managed |
|
||||
| Pi | Local Pi RPC process | Provider-managed |
|
||||
| Pi-direct | Direct Anthropic API call | Stateless |
|
||||
| Mock load test | In-process fake | In-memory |
|
||||
|
||||
All providers:
|
||||
|
||||
@@ -59,30 +59,6 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
@@ -339,8 +315,6 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
@@ -368,14 +342,6 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
|
||||
|
||||
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
@@ -538,12 +504,6 @@ Each entry in the `models` array:
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
@@ -139,9 +139,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
},
|
||||
app: {
|
||||
@@ -158,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -363,11 +362,6 @@ Array of project records.
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
|
||||
@@ -216,7 +216,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -37,38 +37,9 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||
source that drives frame production — stuck on a stale display. The compositor
|
||||
then stops producing frames and the window looks frozen: unresponsive to clicks
|
||||
and keys even though the renderer and every process stay alive. It self-recovers
|
||||
after a few minutes, which is too long for a foreground app.
|
||||
|
||||
`setupDarwinCompositorWatchdog`
|
||||
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
|
||||
this. It polls the renderer for frame production every couple of seconds and,
|
||||
after a sustained stall while the window is visible and unlocked, restarts the
|
||||
GPU process so Chromium rebuilds the display link. The probe is skipped while
|
||||
the screen is locked or the window is hidden or minimized, since a window
|
||||
legitimately stops producing frames then.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
||||
session, and agent-manager traces for stuck-state debugging.
|
||||
|
||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
@@ -187,20 +158,6 @@ Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
## App web deploys
|
||||
|
||||
`packages/app` exports a single-page Expo web app and deploys the `dist/`
|
||||
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
|
||||
|
||||
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
|
||||
from `packages/app/public/index.html`. Keep the install icons in `public/` so
|
||||
Cloudflare serves them from stable root URLs after `expo export`.
|
||||
|
||||
Do not add service-worker caching casually. Paseo is a live control surface for
|
||||
agents, and an aggressive service worker can strand installed users on stale web
|
||||
code. If offline behavior becomes a product requirement, add it deliberately
|
||||
with an update strategy and test the installed-app upgrade path.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# Floating Panels
|
||||
|
||||
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
|
||||
float above an anchor element on iOS, Android, and web. This doc captures the
|
||||
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
|
||||
canonical files and are trying to add or change one.
|
||||
|
||||
## Canonical files
|
||||
|
||||
| File | Use case |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||
input focused while its scrollable list lives in a Portal. There is no shared
|
||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||
until then prefer copying the closest file and trimming.
|
||||
|
||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||
|
||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||
correctly (with `overflow: visible`, the default) but **does not receive touch
|
||||
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
|
||||
rect first, then iterates children. A touch in the overflowing region never
|
||||
reaches the parent, let alone the child. iOS and web do not share this rule —
|
||||
iOS hit-test descends into overflowing children, web uses standard CSS pointer
|
||||
events. This is the bug that put autocomplete on this path: the popover was
|
||||
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
|
||||
Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
full-screen. This is the right default when you must keep IME attachment.
|
||||
Choose the host by layer: app-global overlays use the root host; content
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
A Portal escapes Android's hit-test, but it also escapes two things you were
|
||||
quietly relying on:
|
||||
|
||||
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
|
||||
component's natural ancestor chain. When the user navigates away, your
|
||||
component may stay mounted (offscreen, in a tab) — the popover stays with it.
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
||||
together because they share the SharedValue. A portal'd popover is outside
|
||||
the composer tree — it does not get that transform unless you apply it
|
||||
yourself.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
|
||||
renders inside its host, not necessarily at window origin. Position anchored
|
||||
content relative to the host: `anchorRect - hostRect`. This is what
|
||||
`measureFloatingPanelPortalHost()` is for.
|
||||
|
||||
The fix for transforms is Gotcha 3.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
|
||||
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||
includes Reanimated-applied transforms (Reanimated updates native view
|
||||
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||
with Reanimated worklets the result is not always stable.
|
||||
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same SharedValue the composer uses**:
|
||||
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
to the popover wrapper.
|
||||
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
1. Measure the anchor with `measureInWindow`.
|
||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||
3. Position with anchor coordinates relative to the host:
|
||||
|
||||
```ts
|
||||
left = anchorRect.x - hostRect.x;
|
||||
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
||||
```
|
||||
|
||||
Do this before adding any platform offset. If anchor and host are both measured
|
||||
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
|
||||
Only add a status-bar offset when the render surface is not measured in the same
|
||||
coordinate system. See `tooltip.tsx` for that separate case.
|
||||
|
||||
## Gotcha 5 — The two-measurement flash
|
||||
|
||||
If your popover needs `top` (or `left`) computed from both:
|
||||
|
||||
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
|
||||
- the popover's own size (`contentSize` from `onLayout`),
|
||||
|
||||
then a naïve implementation will flash through three positions on every open:
|
||||
|
||||
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
|
||||
for either measurement. Wrapper has no `width`, so the inner content lays
|
||||
out at its natural (often narrow) intrinsic width.
|
||||
2. **Frame 2** — `anchorRect` lands. Wrapper now has `width: anchorRect.width`.
|
||||
But the stale `onLayout` from frame 1 has already set `contentSize` to the
|
||||
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
|
||||
at the wrong spot.
|
||||
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
|
||||
updates. Position snaps to the right place.
|
||||
|
||||
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
|
||||
both:
|
||||
|
||||
- **Do not mount the floating content until `anchorRect` is set.** Return
|
||||
`null` until then. This prevents the bad-width onLayout from happening at
|
||||
all.
|
||||
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
|
||||
the final width but `opacity: 0`.** The first visible paint is at the
|
||||
correct position. This is the combobox pattern —
|
||||
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
|
||||
`top: -9999` as the placeholder; the layout work still happens at -9999 and
|
||||
any subsequent state-flash is visible when you flip back.
|
||||
|
||||
The "render invisible to measure, then reveal" pattern is the canonical
|
||||
solution to chicken-and-egg positioning in this codebase. Reach for it before
|
||||
anything fancier.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
|
||||
If no, use Portal.
|
||||
2. **Does the panel need to dismiss on screen change?** Almost always yes —
|
||||
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
|
||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||
raw window coordinates as local Portal coordinates.
|
||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||
If no, you can probably skip the transform entirely.
|
||||
5. **Will the panel's content height vary?** If yes, you need both
|
||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||
a known fixed max height — you might be able to use bottom-anchored
|
||||
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
|
||||
`contentSize` round-trip entirely. **But only if the height is genuinely
|
||||
bounded**. Verify before you commit.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
@@ -15,19 +15,12 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
138
docs/hover.md
138
docs/hover.md
@@ -1,138 +0,0 @@
|
||||
# Hover
|
||||
|
||||
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
|
||||
|
||||
## The pattern
|
||||
|
||||
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
|
||||
|
||||
```tsx
|
||||
//
|
||||
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
|
||||
// │
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable // ┐ Separate inner Pressable.
|
||||
onPress={handlePress} // │ Handles press only.
|
||||
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
|
||||
onPressOut={...} // ┘
|
||||
style={workspaceRowStyle}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View style={styles.workspaceRowLeft}>…</View>
|
||||
<WorkspaceRowRightGroup isHovered={isHovered} />
|
||||
{/* └─ Reveals content based on hover state. */}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
```
|
||||
|
||||
Five things make this work. Every one of them matters.
|
||||
|
||||
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
|
||||
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
|
||||
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
|
||||
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
|
||||
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
|
||||
|
||||
That's the whole pattern. Internalize it.
|
||||
|
||||
## When you skip the pattern, here is what breaks
|
||||
|
||||
### Failure mode 1 — Nested Pressables fight over hover state
|
||||
|
||||
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
|
||||
|
||||
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
|
||||
|
||||
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
|
||||
|
||||
### Failure mode 2 — The hovered state changes the trigger's geometry
|
||||
|
||||
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
|
||||
|
||||
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
|
||||
|
||||
Common variants:
|
||||
|
||||
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
|
||||
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
|
||||
- Hover state swaps the trigger for a different element type, remounting it.
|
||||
|
||||
Fixes, in preferred order:
|
||||
|
||||
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
|
||||
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
|
||||
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
|
||||
|
||||
### Failure mode 3 — Revealed content lives outside the hover trigger
|
||||
|
||||
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
<View>
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
</View>
|
||||
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Right:
|
||||
|
||||
```tsx
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
<TrailingRow /> {/* INSIDE — child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
|
||||
|
||||
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
|
||||
|
||||
## Native fallback
|
||||
|
||||
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
|
||||
|
||||
```tsx
|
||||
const showControls = isHovered || isNative || isCompact;
|
||||
```
|
||||
|
||||
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
|
||||
|
||||
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
|
||||
|
||||
## What about `Pressable.onHoverIn` / `onHoverOut`?
|
||||
|
||||
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
|
||||
|
||||
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
|
||||
|
||||
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
|
||||
|
||||
## Real gaps with floating panels
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
## Pre-PR checklist
|
||||
|
||||
Before opening a PR that touches hover:
|
||||
|
||||
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
|
||||
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
|
||||
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
|
||||
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
@@ -249,23 +249,6 @@ const { theme } = useUnistyles();
|
||||
|
||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||
|
||||
## Native Chat Stream Layout
|
||||
|
||||
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
|
||||
|
||||
- chronological stream order
|
||||
- strategy-ordered array order
|
||||
- native inverted cell visual order
|
||||
|
||||
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
|
||||
|
||||
Platform-specific stream edges belong on `StreamStrategy`:
|
||||
|
||||
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
|
||||
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
|
||||
|
||||
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# OpenCode Global Event Verification
|
||||
|
||||
Date: 2026-05-11
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
|
||||
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
Each OpenCode test file was run independently with:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
|
||||
|
||||
## Post-Change Result
|
||||
|
||||
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
|
||||
|
||||
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
|
||||
|
||||
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `git diff --check`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`
|
||||
@@ -71,7 +71,7 @@ Anyone who builds software:
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
|
||||
@@ -14,37 +14,7 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
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 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 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.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
---
|
||||
|
||||
|
||||
128
docs/release.md
128
docs/release.md
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
|
||||
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
@@ -21,11 +21,11 @@ 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`, 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.
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
|
||||
|
||||
**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).
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
@@ -51,18 +51,17 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
|
||||
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
- smoke a build yourself before promoting it to everyone
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||
|
||||
## Staged rollout (stable channel)
|
||||
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
|
||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||
|
||||
@@ -75,16 +74,16 @@ Updater clients only discover a release through those `.yml` manifests, so there
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
|
||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
# 1. Cut and publish (default 24h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
@@ -93,7 +92,7 @@ gh workflow run desktop-rollout.yml \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
@@ -133,7 +132,7 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
@@ -148,63 +147,6 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
||||
|
||||
## Mobile builds (EAS)
|
||||
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
Use the EAS CLI from `packages/app/`:
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# Recent builds (newest first). Pipe to jq for status only.
|
||||
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
|
||||
|
||||
# Filter by platform.
|
||||
npx eas build:list --platform ios --limit 5 --non-interactive --json
|
||||
npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
|
||||
# Inspect a specific build.
|
||||
npx eas build:view <build-id>
|
||||
|
||||
# Stream logs for a build.
|
||||
npx eas build:view <build-id> --json | jq '.logFiles[]'
|
||||
```
|
||||
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`).
|
||||
|
||||
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
|
||||
|
||||
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
|
||||
|
||||
Pattern:
|
||||
|
||||
```jsonc
|
||||
// mcp__paseo__create_schedule arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"every": "15m",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"target": "self", // heartbeat, NOT "new-agent"
|
||||
"cwd": "/path/to/paseo",
|
||||
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
|
||||
}
|
||||
```
|
||||
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build that errors at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as everything is green so the remaining runs do not generate noise.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
|
||||
@@ -265,15 +207,18 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` only lists stable releases. Betas are silent.
|
||||
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
|
||||
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||
- Do not create duplicate entries for each beta on the same version line.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
@@ -285,20 +230,7 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
|
||||
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
|
||||
|
||||
| Wrong (implementation-facing) | Right (user-facing) |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
|
||||
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
|
||||
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
|
||||
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
|
||||
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
|
||||
| Added debouncing to the search input | Search results no longer lag behind typing |
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
@@ -309,12 +241,9 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
|
||||
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
|
||||
|
||||
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
|
||||
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
|
||||
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
|
||||
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
@@ -357,7 +286,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting a **stable** release, run a Codex review of the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
@@ -373,19 +302,15 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
|
||||
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
@@ -394,5 +319,4 @@ The changelog covers **stable-to-stable**. Betas are not represented. When you p
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
|
||||
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store
|
||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# RPC Namespacing
|
||||
|
||||
New WebSocket session RPCs use dotted names with the direction as the final segment:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
checkout.github.set_auto_merge.response;
|
||||
```
|
||||
|
||||
The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Provider or subsystem: `github`
|
||||
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
|
||||
- Direction: `request` or `response`
|
||||
|
||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||
|
||||
## Request/Response Pairs
|
||||
|
||||
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
// -> checkout.github.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
Requests keep their parameters at the top level:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "squash",
|
||||
requestId: "req_123"
|
||||
}
|
||||
```
|
||||
|
||||
Responses put correlated result data under `payload`:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `requestId` in both request and response payloads. It is the correlation key.
|
||||
|
||||
## Provider Namespacing
|
||||
|
||||
Provider-specific behavior belongs under the provider segment:
|
||||
|
||||
- `checkout.github.*` for GitHub-specific checkout operations
|
||||
- `checkout.gitlab.*` for future GitLab-specific checkout operations
|
||||
|
||||
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
|
||||
|
||||
```ts
|
||||
checkout_pr_merge_request;
|
||||
checkout_pr_merge_response;
|
||||
```
|
||||
|
||||
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
|
||||
|
||||
- Add the new names first.
|
||||
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
|
||||
- Keep old names accepted until the compatibility window expires.
|
||||
- Mark shims with `COMPAT(...)` and a removal date.
|
||||
@@ -102,16 +102,16 @@ When a test is labeled end-to-end, it calls the real service. No environment var
|
||||
|
||||
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
|
||||
|
||||
| Suffix | What it is | Where it runs |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
|
||||
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
|
||||
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
|
||||
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
|
||||
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
|
||||
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
|
||||
| Suffix | What it is | Where it runs |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
|
||||
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
|
||||
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
|
||||
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
|
||||
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
|
||||
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
|
||||
|
||||
### Test setup
|
||||
|
||||
@@ -127,7 +127,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -4,17 +4,15 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
|
||||
|
||||
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||
|
||||
## STOP — `useUnistyles()` Is Banned
|
||||
## STOP — `useUnistyles()` Is Forbidden
|
||||
|
||||
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
|
||||
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
|
||||
|
||||
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
|
||||
|
||||
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
|
||||
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
|
||||
|
||||
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
|
||||
|
||||
Use these alternatives in order:
|
||||
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
|
||||
|
||||
### 1. `StyleSheet.create((theme) => ...)` — default
|
||||
|
||||
@@ -48,9 +46,31 @@ const ThemedBlur = withUnistyles(BlurView);
|
||||
|
||||
(Mind the `> *` child-selector leak documented further down.)
|
||||
|
||||
### 4. There is no "last resort"
|
||||
### 4. Lift the read into a tiny leaf component
|
||||
|
||||
There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
|
||||
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
|
||||
|
||||
### 5. (Last resort) `useUnistyles()`
|
||||
|
||||
Only acceptable when both of:
|
||||
|
||||
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
|
||||
- (b) The component is small, leaf-level, and not on a hot render path.
|
||||
|
||||
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
|
||||
|
||||
### Hot-path forbidden list
|
||||
|
||||
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
|
||||
|
||||
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
|
||||
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
|
||||
- `Composer` and `MessageInput`
|
||||
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
|
||||
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
|
||||
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
|
||||
|
||||
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
|
||||
|
||||
## How Updates Propagate
|
||||
|
||||
@@ -60,34 +80,6 @@ The important detail: the automatic native path tracks `props.style`. It does no
|
||||
|
||||
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||
|
||||
## Dynamic Pixel Styles On Web
|
||||
|
||||
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
|
||||
|
||||
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
|
||||
|
||||
## Inline Style Escape Hatch
|
||||
|
||||
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
|
||||
|
||||
```tsx
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
thumb: {
|
||||
position: "absolute",
|
||||
},
|
||||
});
|
||||
|
||||
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
|
||||
```
|
||||
|
||||
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
|
||||
|
||||
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
|
||||
|
||||
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
@@ -26,18 +26,11 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
@@ -3,8 +3,6 @@ pre-commit:
|
||||
jobs:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
exclude:
|
||||
- "**/package-lock.json"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
glob: "*.{js,jsx,ts,tsx}"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
|
||||
# without this, the desktop drv produces a separately-named store path
|
||||
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
|
||||
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
|
||||
paseo,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo-desktop";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
# Website is unrelated to the desktop app
|
||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
||||
# Test fixtures and build artifacts
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store"
|
||||
&& baseName != "release";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit (paseo) npmDeps;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty)
|
||||
makeWrapper
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
env = {
|
||||
EXPO_NO_TELEMETRY = "1";
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
||||
|
||||
# Desktop main process (tsc only — NOT electron-builder)
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/paseo-desktop $out/bin
|
||||
|
||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
||||
# `app.isPackaged` is false, so these relative paths are used.
|
||||
#
|
||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
||||
# Missing any workspace package leaves dangling symlinks and fails the
|
||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
||||
# drops the big platform-specific things (android/ios, website, tests).
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
cp -a packages $out/share/paseo-desktop/
|
||||
cp -a node_modules $out/share/paseo-desktop/
|
||||
|
||||
# Skills directory referenced at runtime by some agents
|
||||
if [ -d skills ]; then
|
||||
cp -a skills $out/share/paseo-desktop/
|
||||
fi
|
||||
|
||||
# Hicolor icon for desktop environments
|
||||
install -Dm644 packages/desktop/assets/icon.png \
|
||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||
|
||||
# Launcher wraps nixpkgs electron.
|
||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
||||
#
|
||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
||||
--add-flags "--no-sandbox" \
|
||||
--set EXPO_DEV_URL "paseo://app/"
|
||||
|
||||
copyDesktopItems
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "paseo-desktop";
|
||||
desktopName = "Paseo";
|
||||
genericName = "AI Coding Agents";
|
||||
comment = "Self-hosted daemon for AI coding agents";
|
||||
exec = "paseo-desktop";
|
||||
icon = "paseo-desktop";
|
||||
categories = [ "Development" ];
|
||||
startupWMClass = "Paseo";
|
||||
})
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
150
nix/module.nix
150
nix/module.nix
@@ -81,59 +81,7 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Whether to enable relay-based remote access. When false, the daemon
|
||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
||||
connections.
|
||||
'';
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "hosted" "remote" ];
|
||||
default = "hosted";
|
||||
description = ''
|
||||
How the daemon reaches the relay when `relay.enable = true`:
|
||||
|
||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
||||
Preserves the current behavior; no extra options needed.
|
||||
- `"remote"`: connect to a self-hosted relay at
|
||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
||||
|
||||
A `"local"` mode (running a relay on the same host as a systemd
|
||||
unit) is not yet implemented — the relay package currently only
|
||||
ships a Cloudflare Workers adapter. Tracked separately.
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "relay.example.com";
|
||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
useTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
publicUseTls = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Whether the public (client-facing) relay endpoint uses TLS.
|
||||
When `null` (default), the daemon falls back to `relay.useTls`.
|
||||
Override when the internal path is plain `ws://` behind a
|
||||
TLS-terminating reverse proxy.
|
||||
'';
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -146,9 +94,8 @@ in
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile, home-manager profile (`~/.nix-profile/bin` and
|
||||
`~/.local/state/nix/profile/bin`), and system paths so agents can use
|
||||
them without manually setting PATH.
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
@@ -164,50 +111,9 @@ in
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = (pkgs.formats.json { }).type;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
||||
agents.providers.myAcp = {
|
||||
extends = "acp";
|
||||
label = "My Agent";
|
||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
||||
};
|
||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
||||
and installed on every service start.
|
||||
|
||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
||||
or the mobile app toggling MCP injection / provider overrides) are
|
||||
overwritten on the next restart. Pick one: manage via this option, or
|
||||
manage via the CLI — not both.
|
||||
|
||||
The full schema is defined by `PersistedConfigSchema` in
|
||||
`packages/server/src/server/persisted-config.ts`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
||||
in
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
||||
message = ''
|
||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -225,47 +131,24 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment (
|
||||
let
|
||||
# Match dataDir's convention. We can't read users.users.<name>.home
|
||||
# because the user may be managed outside NixOS.
|
||||
userHome = "/home/${cfg.user}";
|
||||
in {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). When the
|
||||
# daemon runs as a real user, also include home-manager profile paths
|
||||
# so user-installed CLIs (claude, opencode, codex, ...) are reachable
|
||||
# by agent processes the daemon spawns.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" (
|
||||
lib.optionals (cfg.user != "paseo") [
|
||||
"${userHome}/.nix-profile/bin"
|
||||
"${userHome}/.local/state/nix/profile/bin"
|
||||
]
|
||||
++ [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]
|
||||
));
|
||||
}
|
||||
) // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) {
|
||||
PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false";
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -289,6 +172,5 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
sha256-WAxd4FSYNGYPT7v0QYTkYF5AacMrpYU+zjMyMTcWufo=
|
||||
@@ -7,15 +7,6 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
||||
#
|
||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
||||
# the hash with a single file write instead of a sed against this source.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -49,9 +40,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
@@ -88,25 +79,47 @@ buildNpmPackage rec {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
mkdir -p $out/lib/paseo
|
||||
node scripts/trace-daemon.mjs > daemon-files.txt
|
||||
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
mkdir -p "$out/lib/paseo/$(dirname "$path")"
|
||||
cp -a "$path" "$out/lib/paseo/$path"
|
||||
done < daemon-files.txt
|
||||
|
||||
# Root package.json lets node resolve the workspace layout when the
|
||||
# CLI/server bin starts from $out.
|
||||
# Copy root package metadata
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy server scripts (including supervisor-entrypoint) needed by CLI
|
||||
if [ -d packages/server/dist/scripts ]; then
|
||||
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
|
||||
5476
package-lock.json
generated
5476
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.71",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -93,7 +93,6 @@
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, submit_ios]
|
||||
needs: [submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
@@ -49,7 +49,4 @@ jobs:
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: |
|
||||
export APP_VERSION="${{ needs.build_ios.outputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ needs.build_ios.outputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
run: bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
name: Resubmit iOS for App Store review
|
||||
|
||||
# Standalone re-trigger for the App Store review submission step. The iOS
|
||||
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push
|
||||
# build; this workflow just runs the fastlane submit_review lane against the
|
||||
# latest TestFlight build, without rebuilding or re-uploading.
|
||||
# binary is already uploaded to TestFlight via the main release-mobile flow;
|
||||
# this workflow just runs the fastlane submit_review lane against the latest
|
||||
# TestFlight build, without rebuilding or re-uploading.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
|
||||
app_build_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
submit_ios_for_review:
|
||||
@@ -27,7 +18,4 @@ jobs:
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: |
|
||||
export APP_VERSION="${{ inputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
run: bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "~> 2.234"
|
||||
gem "fastlane"
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
@@ -43,23 +42,4 @@ test.describe("Agent stream UI", () => {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const prompt = "Stream briefly for first app-created turn timer 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" });
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
expectSessionRowArchived,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
interface SlashCommandScenario {
|
||||
agent: { id: string };
|
||||
client: TerminalPerfDaemonClient;
|
||||
cwd: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function withOpenReadyMockAgent(
|
||||
page: Page,
|
||||
input: {
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
run: (scenario: SlashCommandScenario) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const repo = await createTempGitRepo("client-slash-command-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await createReadyMockAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modeId: input.modeId,
|
||||
});
|
||||
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await run({ agent, client, cwd: repo.path, title: input.title });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReadyMockAgent(
|
||||
client: TerminalPerfDaemonClient,
|
||||
input: {
|
||||
cwd: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
modeId: input.modeId ?? "load-test",
|
||||
model: input.model ?? "ten-second-stream",
|
||||
initialPrompt: "Prepare a client slash command test agent.",
|
||||
});
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
async function openActiveAgentTab(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceTabVisible(page, input.agentId);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(command);
|
||||
await expect(input).toHaveValue(command);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function selectClientSlashCommand(page: Page, query: string, label: string): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function expectAgentArchivedInSessions(page: Page, title: string): Promise<void> {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, title);
|
||||
}
|
||||
|
||||
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
|
||||
await expectComposerVisible(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Select model (Ten second stream)" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
|
||||
await submitMessage(page, REPLACEMENT_PROMPT);
|
||||
}
|
||||
|
||||
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
|
||||
let newAgentId: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const ids = await page
|
||||
.locator('[data-testid^="workspace-tab-agent_"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.flatMap((node) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
const testId = node.getAttribute("data-testid") ?? "";
|
||||
if (!testId.startsWith("workspace-tab-agent_")) {
|
||||
return [];
|
||||
}
|
||||
if (node.offsetParent === null) {
|
||||
return [];
|
||||
}
|
||||
return [testId.slice("workspace-tab-agent_".length)];
|
||||
}),
|
||||
);
|
||||
newAgentId = ids.find((id) => id !== oldAgentId) ?? null;
|
||||
return newAgentId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
if (!newAgentId) {
|
||||
throw new Error("Replacement agent was not created.");
|
||||
}
|
||||
return newAgentId;
|
||||
}
|
||||
|
||||
test.describe("Client slash commands", () => {
|
||||
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
|
||||
await runClientSlashCommand(page, "/quit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
});
|
||||
});
|
||||
|
||||
test("slash quit selected from autocomplete archives immediately", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash quit autocomplete e2e" },
|
||||
async ({ agent, title }) => {
|
||||
await selectClientSlashCommand(page, "/qu", "/exit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("slash clear replaces the active agent with a matching draft", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
||||
async ({ agent, title }) => {
|
||||
await runClientSlashCommand(page, "/clear");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectReplacementDraftMatchesPreviousSetup(page);
|
||||
await createAgentFromReplacementDraft(page);
|
||||
await waitForReplacementAgentId(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,536 +0,0 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
name: "tdd",
|
||||
description: "Write a red test, verify it fails for the right reason, implement to green",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
description: "Show help for the current agent session and available slash commands",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "hello",
|
||||
description: "Insert a friendly greeting prompt into the current composer",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "heapdump",
|
||||
description: "Dump the JavaScript heap for local desktop debugging",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "health",
|
||||
description: "Show runtime health checks and connection diagnostics",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
description: "Summarize recent session history",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "handoff",
|
||||
description: "Prepare a complete handoff note for another agent",
|
||||
argumentHint: "[agent]",
|
||||
},
|
||||
{
|
||||
name: "hover",
|
||||
description: "Audit hover behavior in desktop web surfaces",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "harness",
|
||||
description: "Inspect the local test harness configuration",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "hydrate",
|
||||
description: "Refresh persisted state used by the current workspace",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "highlight",
|
||||
description: "Highlight important changes in the active diff",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "home",
|
||||
description: "Navigate back to the workspace home surface",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
description: "Inspect host connection metadata",
|
||||
argumentHint: "",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface PopoverFrame {
|
||||
exists: boolean;
|
||||
top: number;
|
||||
bottom: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
display: string;
|
||||
visibility: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PopoverFrameRecorderWindow extends Window {
|
||||
__composerAutocompleteFrames?: PopoverFrame[];
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort || daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
commands?: unknown;
|
||||
error?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (
|
||||
parsed.type === "session" &&
|
||||
parsed.message?.type === "list_commands_response" &&
|
||||
parsed.message.payload
|
||||
) {
|
||||
parsed.message.payload.commands = TEST_COMMANDS;
|
||||
parsed.message.payload.error = null;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Forward non-JSON frames unchanged.
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
|
||||
const operation = cleanup().catch(() => undefined);
|
||||
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
): Promise<{
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const repo = await createTempGitRepo("autocomplete-popover-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Autocomplete popover regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const route = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
repo.path,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(route);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
if (options?.expectWorkspaceTab !== false) {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
await expectComposerVisible(page);
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await cleanupWithin(2_000, () => client.close());
|
||||
await cleanupWithin(2_000, () => repo.cleanup());
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanupWithin(2_000, () => client.close());
|
||||
await cleanupWithin(2_000, () => repo.cleanup());
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function visiblePopoverBox(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
popover.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return Number(style.opacity);
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0.95);
|
||||
const box = await popover.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Autocomplete popover did not produce a bounding box.");
|
||||
}
|
||||
return {
|
||||
top: box.y,
|
||||
bottom: box.y + box.height,
|
||||
height: box.height,
|
||||
};
|
||||
}
|
||||
|
||||
async function startPopoverFrameRecorder(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const win = window as PopoverFrameRecorderWindow;
|
||||
win.__composerAutocompleteFrames = [];
|
||||
let active = true;
|
||||
|
||||
const record = () => {
|
||||
if (!active) return;
|
||||
|
||||
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
|
||||
if (element instanceof HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
win.__composerAutocompleteFrames?.push({
|
||||
exists: true,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
opacity: Number(style.opacity),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
timestamp: performance.now(),
|
||||
});
|
||||
} else {
|
||||
win.__composerAutocompleteFrames?.push({
|
||||
exists: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
display: "none",
|
||||
visibility: "hidden",
|
||||
timestamp: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(record);
|
||||
};
|
||||
|
||||
win.__stopComposerAutocompleteFrameRecorder = () => {
|
||||
active = false;
|
||||
};
|
||||
window.requestAnimationFrame(record);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as PopoverFrameRecorderWindow;
|
||||
win.__stopComposerAutocompleteFrameRecorder?.();
|
||||
return win.__composerAutocompleteFrames ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
|
||||
return frames.filter(
|
||||
(frame) =>
|
||||
frame.exists &&
|
||||
frame.height > 0 &&
|
||||
frame.opacity > 0.95 &&
|
||||
frame.display !== "none" &&
|
||||
frame.visibility !== "hidden",
|
||||
);
|
||||
}
|
||||
|
||||
function formatFrame(frame: PopoverFrame | undefined): string {
|
||||
if (!frame) return "none";
|
||||
return JSON.stringify({
|
||||
top: Math.round(frame.top),
|
||||
bottom: Math.round(frame.bottom),
|
||||
height: Math.round(frame.height),
|
||||
opacity: frame.opacity,
|
||||
});
|
||||
}
|
||||
|
||||
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
const visibleFrames = visiblePopoverFrames(frames);
|
||||
expect(visibleFrames.length).toBeGreaterThan(0);
|
||||
|
||||
const finalFrame = visibleFrames[visibleFrames.length - 1];
|
||||
const jumpingFrame = visibleFrames.find(
|
||||
(frame) =>
|
||||
Math.abs(frame.top - finalFrame.top) > 4 ||
|
||||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
|
||||
Math.abs(frame.height - finalFrame.height) > 4,
|
||||
);
|
||||
|
||||
expect(
|
||||
jumpingFrame,
|
||||
`expected first visible popover paint to be stable; first=${formatFrame(
|
||||
visibleFrames[0],
|
||||
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
frame.exists &&
|
||||
frame.height > 0 &&
|
||||
frame.opacity > 0.95 &&
|
||||
frame.display !== "none" &&
|
||||
frame.visibility !== "hidden",
|
||||
);
|
||||
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const hiddenFrame = frames
|
||||
.slice(firstVisibleIndex)
|
||||
.find((frame) => !frame.exists || frame.opacity < 0.95);
|
||||
expect(
|
||||
hiddenFrame,
|
||||
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.click();
|
||||
|
||||
await startPopoverFrameRecorder(page);
|
||||
await page.keyboard.type("/");
|
||||
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(250);
|
||||
const frames = await stopPopoverFrameRecorder(page);
|
||||
|
||||
expectPopoverFramesStable(frames);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not jump when deleting a slash command search", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/he");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const beforeDelete = await visiblePopoverBox(page);
|
||||
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/h");
|
||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const afterDelete = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
|
||||
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shrinks to filtered slash command results without moving the bottom edge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const allCommands = await visiblePopoverBox(page);
|
||||
|
||||
await input.fill("/tdd");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const oneCommand = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
|
||||
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays visible while filtering slash command results", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await startPopoverFrameRecorder(page);
|
||||
await page.keyboard.type("tdd", { delay: 40 });
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
const frames = await stopPopoverFrameRecorder(page);
|
||||
|
||||
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const composerBox = await page.getByTestId("message-input-root").boundingBox();
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeToolBinDir: string | null = null;
|
||||
let fakeGhBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
@@ -209,8 +209,8 @@ interface OfferPayload {
|
||||
relay: { endpoint: string };
|
||||
}
|
||||
|
||||
async function createFakeToolBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
|
||||
async function createFakeGhBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
@@ -284,27 +284,6 @@ forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
|
||||
|
||||
if (recordPath) {
|
||||
fs.appendFileSync(recordPath, JSON.stringify({
|
||||
command: path.basename(process.argv[1]),
|
||||
args: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
at: Date.now()
|
||||
}) + "\\n");
|
||||
}
|
||||
`;
|
||||
for (const editorCommand of ["cursor", "code"]) {
|
||||
const editorPath = path.join(binDir, editorCommand);
|
||||
await writeFile(editorPath, fakeEditorSource);
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -537,22 +516,13 @@ async function awaitRelayReady(
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
async function startRelay(): Promise<number> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const relayPort = await getAvailablePort();
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -629,8 +599,7 @@ interface DaemonSpawnArgs {
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeToolBinDir: string;
|
||||
editorRecordPath: string;
|
||||
fakeGhBinDir: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
@@ -644,9 +613,8 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
@@ -710,9 +678,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeToolBinDir) {
|
||||
await rm(fakeToolBinDir, { recursive: true, force: true });
|
||||
fakeToolBinDir = null;
|
||||
if (fakeGhBinDir) {
|
||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||
fakeGhBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,8 +694,7 @@ export default async function globalSetup() {
|
||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
||||
fakeToolBinDir = await createFakeToolBin();
|
||||
fakeGhBinDir = await createFakeGhBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
@@ -738,15 +705,14 @@ export default async function globalSetup() {
|
||||
const dictation = await resolveDictationConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
const relayPort = await startRelay();
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeToolBinDir,
|
||||
editorRecordPath,
|
||||
fakeGhBinDir,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
@@ -776,7 +742,6 @@ export default async function globalSetup() {
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||
*/
|
||||
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||
page: Page,
|
||||
messageType: string,
|
||||
extract: (inner: Record<string, unknown>) => T,
|
||||
): T[] {
|
||||
const captured: T[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
const raw = frame.payload;
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||
captured.push(extract(outer.message));
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON and binary frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-input`);
|
||||
}
|
||||
|
||||
export function renameModalSubmit(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-submit`);
|
||||
}
|
||||
|
||||
export function renameModalError(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-error`);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
page: Page;
|
||||
client: TerminalPerfDaemonClient;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string | RegExp;
|
||||
}
|
||||
|
||||
interface ProviderLaunchConfig {
|
||||
provider: RewindFlowProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
const REWIND_TIMEOUT_MS = 120_000;
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return { provider, model: "haiku", modeId: "bypassPermissions" };
|
||||
case "codex":
|
||||
return {
|
||||
provider,
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
};
|
||||
case "opencode":
|
||||
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
model: "openrouter/google/gemini-2.5-flash-lite",
|
||||
thinkingOptionId: "medium",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
function visibleChatMessages(page: Page) {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
|
||||
}
|
||||
|
||||
async function transcript(
|
||||
page: Page,
|
||||
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
|
||||
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
|
||||
| "user"
|
||||
| "assistant",
|
||||
text: (element.textContent ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
|
||||
.trim(),
|
||||
})),
|
||||
);
|
||||
|
||||
return coalesceAssistantTurnSegments(rawMessages);
|
||||
}
|
||||
|
||||
function coalesceAssistantTurnSegments(
|
||||
messages: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
): Array<{ role: "user" | "assistant"; text: string }> {
|
||||
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const previous = transcriptMessages.at(-1);
|
||||
if (message.role === "assistant" && previous?.role === "assistant") {
|
||||
const joinedText =
|
||||
previous.text && message.text
|
||||
? `${previous.text}\n${message.text}`
|
||||
: previous.text || message.text;
|
||||
transcriptMessages[transcriptMessages.length - 1] = {
|
||||
role: "assistant",
|
||||
text: joinedText,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
transcriptMessages.push(message);
|
||||
}
|
||||
|
||||
return transcriptMessages.filter(
|
||||
(message) => message.role !== "assistant" || message.text.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
|
||||
if (typeof expected === "string") {
|
||||
return actual === expected;
|
||||
}
|
||||
return expected.test(actual);
|
||||
}
|
||||
|
||||
function formatExpectedMessage(message: TranscriptMessage): string {
|
||||
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
|
||||
return `${message.role}:${text}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
export async function launchAgent(input: {
|
||||
page: Page;
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
|
||||
const before = await transcript(handle.page);
|
||||
await submitMessage(handle.page, text);
|
||||
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
|
||||
if (finish.status !== "idle") {
|
||||
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
|
||||
throw new Error(
|
||||
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
|
||||
);
|
||||
}
|
||||
if (finish.final?.lastError) {
|
||||
throw new Error(finish.final.lastError);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page), { timeout: 30_000 })
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", text }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(before.length + 2);
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertChatTranscript(
|
||||
handle: Pick<AgentHandle, "page">,
|
||||
expected: TranscriptMessage[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
return JSON.stringify(actual);
|
||||
}
|
||||
const matches = actual.every(
|
||||
(message, index) =>
|
||||
message.role === expected[index]?.role &&
|
||||
expectedTextMatches(message.text, expected[index]!.text),
|
||||
);
|
||||
return matches ? "match" : JSON.stringify(actual);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("match");
|
||||
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`Expected ${expected.length} chat messages (${expected
|
||||
.map(formatExpectedMessage)
|
||||
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindMessage(
|
||||
handle: AgentHandle,
|
||||
userMessageIndex: number,
|
||||
mode: RewindFlowMode,
|
||||
): Promise<void> {
|
||||
const beforeEpoch = await fetchTimelineEpoch(handle);
|
||||
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
|
||||
const userText = userMessages[userMessageIndex]?.text;
|
||||
if (!userText) {
|
||||
throw new Error(`No user message found at index ${userMessageIndex}`);
|
||||
}
|
||||
await userMessage
|
||||
.getByText(new RegExp(escapeRegExp(userText)))
|
||||
.first()
|
||||
.hover();
|
||||
const trigger = userMessage.getByTestId("rewind-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
|
||||
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
|
||||
await expect(modeItem).toBeVisible({ timeout: 10_000 });
|
||||
await modeItem.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
|
||||
if (mode !== "files") {
|
||||
await waitForNextTimelineEpoch(handle, beforeEpoch);
|
||||
}
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertFileExists(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function assertFileMissing(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
|
||||
}
|
||||
|
||||
export async function assertFileContains(filePath: string, text: string): Promise<void> {
|
||||
await assertFileExists(filePath);
|
||||
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupRewindFlow(input: {
|
||||
handle?: AgentHandle;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
if (input.handle) {
|
||||
await closeAgent(input.handle);
|
||||
}
|
||||
rmSync(input.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||
const client = handle.client as TerminalPerfDaemonClient & {
|
||||
fetchAgentTimeline: (
|
||||
agentId: string,
|
||||
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
||||
) => Promise<{ epoch?: string }>;
|
||||
};
|
||||
const timeline = await client.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
limit: 0,
|
||||
});
|
||||
return timeline.epoch;
|
||||
}
|
||||
|
||||
async function waitForNextTimelineEpoch(
|
||||
handle: AgentHandle,
|
||||
beforeEpoch: string | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
|
||||
.not.toBe(beforeEpoch);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
@@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||
|
||||
@@ -21,13 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await page.getByTestId("menu-button").click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("sidebar-close");
|
||||
await expect(closeButton).toBeInViewport({ timeout: 5_000 });
|
||||
await closeButton.click({ force: true });
|
||||
await page.getByTestId("menu-button").click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -30,20 +29,7 @@ export interface TerminalPerfDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
@@ -77,7 +63,6 @@ interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
@@ -101,22 +86,12 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: loadAppVersion(),
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "claude",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "codex",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "opencode",
|
||||
initialRewindMode: "both",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "pi",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
assertComposerIdle,
|
||||
assertFileContains,
|
||||
assertFileExists,
|
||||
assertFileMissing,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
rewindMessage,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowMode,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
|
||||
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
|
||||
|
||||
interface RewindFlowCase {
|
||||
provider: RewindFlowProvider;
|
||||
initialRewindMode?: RewindFlowMode;
|
||||
rewindMode: RewindFlowMode;
|
||||
fileReverted: boolean;
|
||||
}
|
||||
|
||||
export function defineRewindFlowSpec(input: RewindFlowCase): void {
|
||||
test.describe(`rewind flow - ${input.provider}`, () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
|
||||
await assertChatTranscript(handle, []);
|
||||
await assertComposerIdle(handle);
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FILE_PROMPT);
|
||||
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
{ role: "user", text: /Use the Write tool/ },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 1, input.rewindMode);
|
||||
if (input.fileReverted) {
|
||||
await assertFileMissing(path.join(cwd, "qa.txt"));
|
||||
} else {
|
||||
await assertFileExists(path.join(cwd, "qa.txt"));
|
||||
}
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
await assertComposerIdle(handle);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 1);
|
||||
await submitMessage(page, secondPrompt);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
await expectComposerDraft(page, firstPrompt);
|
||||
|
||||
await submitMessage(page, replacementPrompt);
|
||||
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-failure-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
||||
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind failure e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
featureValues: {
|
||||
mockRewindError: rewindError,
|
||||
},
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
|
||||
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function workspaceRenameModalTestId(workspaceId: string, suffix: string): string {
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openProjectViaDaemon(
|
||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||
cwd: string,
|
||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||
const result = await client.openProject(cwd);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
name: result.workspace.name,
|
||||
workspaceDirectory: result.workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
return input;
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace rename", () => {
|
||||
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-rename-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
expect(workspace.name).toBe("main");
|
||||
|
||||
const renameRequests = captureWsSessionFrames(
|
||||
page,
|
||||
"checkout.rename_branch.request",
|
||||
(inner) => ({
|
||||
branch: String(inner.branch ?? ""),
|
||||
cwd: String(inner.cwd ?? ""),
|
||||
}),
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const input = await openRenameModal(page, workspace.id);
|
||||
await expect(input).toHaveValue("main");
|
||||
await input.fill("Feature Rename 2");
|
||||
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
|
||||
"feature-rename-2",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
expect(renameRequests.length).toBeGreaterThan(0);
|
||||
expect(renameRequests.at(-1)).toEqual({
|
||||
branch: "feature-rename-2",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
});
|
||||
|
||||
const currentBranchOnDisk = execSync("git branch --show-current", {
|
||||
cwd: repo.path,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(currentBranchOnDisk).toBe("feature-rename-2");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
const input = await openRenameModal(page, workspace.id);
|
||||
await expect(input).toHaveValue("main");
|
||||
|
||||
await input.fill("taken");
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||
|
||||
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
|
||||
await expect(errorNode).toBeVisible({ timeout: 15_000 });
|
||||
await expect(errorNode).toContainText(/already exists|branch/i);
|
||||
await expect(input).toBeVisible();
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
|
||||
const OSC11_CAPTURE_SCRIPT = `
|
||||
let captured = Buffer.alloc(0);
|
||||
|
||||
function finish() {
|
||||
process.stdout.write("PASEO_OSC11_CAPTURE:" + JSON.stringify(captured.toString("latin1")) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write("\\x1b]11;?\\x07");
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.on("data", (chunk) => {
|
||||
captured = Buffer.concat([captured, chunk]);
|
||||
if (captured.includes(Buffer.from("rgb:"))) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
setTimeout(finish, 700);
|
||||
`;
|
||||
|
||||
test.describe("Terminal protocol queries", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
|
||||
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("does not send browser OSC 11 color-query replies back to the PTY", async ({ page }) => {
|
||||
const terminalInstance = await harness.createTerminal({ name: "osc11-query" });
|
||||
try {
|
||||
await harness.openTerminal(page, { terminalId: terminalInstance.id });
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
const terminal = harness.terminalSurface(page);
|
||||
await terminal.pressSequentially("node osc11-capture.cjs\n", { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes("PASEO_OSC11_CAPTURE:"), 10_000);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const text = await getTerminalBufferText(page);
|
||||
|
||||
expect(text).toContain("rgb:0b0b/0b0b/0b0b");
|
||||
expect(text).not.toContain("rgb:ffff/ffff/ffff");
|
||||
} finally {
|
||||
await harness.killTerminal(terminalInstance.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerEditable,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectIdleComposer(page: Page): Promise<void> {
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
||||
await expectIdleComposer(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
await expectIdleComposer(page);
|
||||
}
|
||||
|
||||
test.describe("User message UI contract", () => {
|
||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("user-message-contract-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const prompts = [
|
||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn three.",
|
||||
];
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "User message contract e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const prompt = prompts[index]!;
|
||||
await submitMessage(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expectUserMessageCount(page, index + 1);
|
||||
await expectNoLoadingRegressionAfterIdle(page);
|
||||
}
|
||||
|
||||
await fillComposerDraft(page, "append");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await expectUserMessageCount(page, 3);
|
||||
await expectIdleComposer(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabVisible,
|
||||
} from "./helpers/archive-tab";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
|
||||
test.describe("Workspace agent tab rename", () => {
|
||||
test("right-click rename sends update_agent_request and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-agent-rename-");
|
||||
|
||||
try {
|
||||
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
|
||||
agentId: String(inner.agentId ?? ""),
|
||||
name: String(inner.name ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await openAgentInWorkspace(page, agent);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
await expect(tab).toContainText(initialTitle, { timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(input).toHaveValue(initialTitle);
|
||||
|
||||
const renamed = "My Renamed Agent";
|
||||
await input.fill(renamed);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText(renamed, { timeout: 15_000 });
|
||||
|
||||
expect(updateFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = updateFrames.at(-1)!;
|
||||
expect(lastFrame.agentId).toBe(agent.id);
|
||||
expect(lastFrame.name).toBe(renamed);
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface WorkspaceTabProbeRecord {
|
||||
at: number;
|
||||
tabs: Array<{
|
||||
testId: string;
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CapturedCreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
configTitle: string | null;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type ProbeRecord = WorkspaceTabProbeRecord;
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
|
||||
};
|
||||
|
||||
const win = window as ProbeWindow;
|
||||
win.__workspaceTabTitleProbe?.stop();
|
||||
|
||||
const records: ProbeRecord[] = [];
|
||||
const isVisible = (element: Element): element is HTMLElement => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
};
|
||||
const snapshot = () => {
|
||||
records.push({
|
||||
at: performance.now(),
|
||||
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
|
||||
.filter(isVisible)
|
||||
.map((element) => ({
|
||||
testId: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
ariaLabel: element.getAttribute("aria-label") ?? "",
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
snapshot();
|
||||
const observer = new MutationObserver(snapshot);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-label", "class", "data-testid", "style"],
|
||||
});
|
||||
const interval = window.setInterval(snapshot, 20);
|
||||
win.__workspaceTabTitleProbe = {
|
||||
records,
|
||||
stop: () => {
|
||||
observer.disconnect();
|
||||
window.clearInterval(interval);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
|
||||
return page.evaluate(() => {
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
|
||||
};
|
||||
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
|
||||
probe?.stop();
|
||||
return probe?.records ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
|
||||
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
|
||||
}
|
||||
|
||||
function createFrameStartsWithPrompt(
|
||||
frame: CapturedCreateAgentFrame,
|
||||
promptTitle: string,
|
||||
): boolean {
|
||||
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
|
||||
}
|
||||
|
||||
function countCreateFramesForPrompt(
|
||||
frames: CapturedCreateAgentFrame[],
|
||||
promptTitle: string,
|
||||
): number {
|
||||
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
|
||||
}
|
||||
|
||||
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
|
||||
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
|
||||
}
|
||||
|
||||
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
|
||||
return record.tabs.some(tabHasLoadingTitle);
|
||||
}
|
||||
|
||||
async function waitForCreatedAgentId(
|
||||
client: TerminalPerfDaemonClient,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries
|
||||
.filter((entry) => entry.agent.cwd === cwd)
|
||||
.map((entry) => entry.agent.id);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
|
||||
if (!agent) {
|
||||
throw new Error(`Expected one created agent in ${cwd}`);
|
||||
}
|
||||
return agent.agent.id;
|
||||
}
|
||||
|
||||
async function fetchActiveAgentTitle(
|
||||
client: TerminalPerfDaemonClient,
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
|
||||
const promptTab = page.getByRole("button", { name: promptTitle }).first();
|
||||
await expect(promptTab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
await promptTab.click({ button: "right" });
|
||||
const renameAction = page.getByText("Rename", { exact: true }).first();
|
||||
if (await renameAction.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const client = await connectTerminalClient();
|
||||
const repo = await createTempGitRepo("workspace-title-handoff-");
|
||||
|
||||
try {
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? "Failed to open test workspace");
|
||||
}
|
||||
|
||||
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
configTitle: typeof config.title === "string" ? config.title : null,
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
const generatedTitle = "Generated Handoff Title";
|
||||
await installWorkspaceTabProbe(page);
|
||||
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
||||
|
||||
const agentId = await waitForCreatedAgentId(client, repo.path);
|
||||
await expect
|
||||
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
expect(createFrames.at(-1)).toEqual({
|
||||
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
|
||||
configTitle: null,
|
||||
});
|
||||
|
||||
await waitForPromptTabAgentActions(page, promptTitle);
|
||||
|
||||
await client.updateAgent(agentId, { name: generatedTitle });
|
||||
await expect
|
||||
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
|
||||
.toBe(generatedTitle);
|
||||
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const records = await readWorkspaceTabProbe(page);
|
||||
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
|
||||
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
||||
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenRecord[]> {
|
||||
try {
|
||||
const contents = await readFile(recordPath, "utf8");
|
||||
return contents
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as EditorOpenRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-open-in-editor-caret").click();
|
||||
await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible();
|
||||
await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click();
|
||||
}
|
||||
|
||||
async function expectEditorOpened(input: {
|
||||
recordPath: string;
|
||||
command: string;
|
||||
workspacePath: string;
|
||||
afterCount: number;
|
||||
}): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some(
|
||||
(record) =>
|
||||
record.command === input.command && record.args.includes(input.workspacePath),
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe("Workspace open in editor", () => {
|
||||
test("keeps the selected editor target after leaving and returning to the workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await injectDesktopBridge(page, { serverId });
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: recordsAfterSelection,
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await gotoAppShell(page);
|
||||
await workspace.navigateTo();
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: recordsAfterReturnOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
} from "./helpers/archive-tab";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
test("opening the first split pane keeps the existing agent composer mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("pane-remount-");
|
||||
let agentId: string | null = null;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: `pane-remount-${Date.now()}`,
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const originalComposer = await page
|
||||
.getByTestId("message-input-root")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
|
||||
await page.getByRole("button", { name: "Split pane right" }).first().click();
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
2,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectTerminalClient();
|
||||
const repo = await createTempGitRepo("workspace-terminal-rename-");
|
||||
|
||||
try {
|
||||
const seeded = await client.openProject(repo.path);
|
||||
if (!seeded.workspace) {
|
||||
throw new Error(seeded.error ?? "Failed to seed workspace");
|
||||
}
|
||||
const workspaceId = seeded.workspace.id;
|
||||
|
||||
const created = await client.createTerminal(repo.path);
|
||||
if (!created.terminal) {
|
||||
throw new Error(created.error ?? "Failed to create terminal");
|
||||
}
|
||||
const terminalId = created.terminal.id;
|
||||
|
||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||
terminalId: String(inner.terminalId ?? ""),
|
||||
title: String(inner.title ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await input.fill("My Renamed Terminal");
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||
|
||||
expect(renameFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = renameFrames.at(-1)!;
|
||||
expect(lastFrame.terminalId).toBe(terminalId);
|
||||
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ platform :ios do
|
||||
key_filepath: ENV.fetch("ASC_KEY_P8"),
|
||||
)
|
||||
|
||||
build_number = latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
)
|
||||
|
||||
require "spaceship"
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
@@ -20,58 +25,30 @@ platform :ios do
|
||||
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
|
||||
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
|
||||
|
||||
# The release workflow forwards the marketing version and CFBundleVersion of
|
||||
# the binary it just built. Use them to identify the exact build, since
|
||||
# CFBundleVersion is not unique across marketing versions in this project
|
||||
# (the production profile resets it per app_version). The resubmit workflow
|
||||
# leaves these unset and falls back to "most recently uploaded iOS build".
|
||||
target_version = ENV["APP_VERSION"].to_s.strip
|
||||
target_build = ENV["APP_BUILD_VERSION"].to_s.strip
|
||||
target_version = nil if target_version.empty?
|
||||
target_build = nil if target_build.empty?
|
||||
|
||||
if target_version
|
||||
UI.message("Targeting App Store version #{target_version}#{target_build ? " build #{target_build}" : ""} from workflow input")
|
||||
else
|
||||
UI.message("No APP_VERSION provided; selecting the most recently uploaded iOS build")
|
||||
end
|
||||
|
||||
valid_build = nil
|
||||
deadline = Time.now + (30 * 60)
|
||||
loop do
|
||||
builds = Spaceship::ConnectAPI::Build.all(
|
||||
app_id: app.id,
|
||||
sort: "-uploadedDate",
|
||||
limit: 100,
|
||||
).select { |b| b.pre_release_version&.platform == "IOS" }
|
||||
builds = builds.select { |b| b.pre_release_version&.version == target_version } if target_version
|
||||
builds = builds.select { |b| b.version == target_build } if target_build
|
||||
|
||||
build = builds.first
|
||||
if build.nil?
|
||||
UI.user_error!("Timed out waiting for build to appear on App Store Connect.") if Time.now > deadline
|
||||
UI.message("No matching iOS build visible on App Store Connect yet; waiting...")
|
||||
sleep(30)
|
||||
next
|
||||
end
|
||||
|
||||
state = build.processing_state
|
||||
UI.message("Build #{build.pre_release_version.version} (#{build.version}) processing state: #{state || 'unknown'}")
|
||||
build_number: build_number.to_s,
|
||||
)
|
||||
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||
state = build&.processing_state
|
||||
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||
if state == "VALID"
|
||||
valid_build = build
|
||||
break
|
||||
end
|
||||
if state == "INVALID" || state == "FAILED"
|
||||
UI.user_error!("Build #{build.version} failed App Store processing (state: #{state}).")
|
||||
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||
end
|
||||
if Time.now > deadline
|
||||
UI.user_error!("Timed out waiting for build #{build.version} to finish processing on App Store Connect.")
|
||||
UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
|
||||
end
|
||||
sleep(30)
|
||||
end
|
||||
|
||||
app_version = valid_build.pre_release_version.version
|
||||
build_number = valid_build.version
|
||||
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
|
||||
|
||||
# Ensure an editable App Store version exists for this marketing version.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
require 'json'
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'PaseoHardwareKeyboard'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.description = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.license = 'AGPL-3.0-or-later'
|
||||
s.author = 'Paseo'
|
||||
s.homepage = 'https://paseo.sh'
|
||||
s.platforms = { :ios => '13.4' }
|
||||
s.swift_version = '5.4'
|
||||
s.source = { :path => '.' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,swift}"
|
||||
end
|
||||
@@ -1,99 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
|
||||
@objc
|
||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
||||
public override func createRootViewController() -> UIViewController? {
|
||||
return PaseoHardwareKeyboardRootViewController()
|
||||
}
|
||||
}
|
||||
|
||||
public class PaseoHardwareKeyboardModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
|
||||
Events(hardwareSubmitEventName)
|
||||
|
||||
OnCreate {
|
||||
activeModule = self
|
||||
}
|
||||
|
||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||
DispatchQueue.main.async {
|
||||
isHardwareSubmitEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
if activeModule === self {
|
||||
activeModule = nil
|
||||
}
|
||||
isHardwareSubmitEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardSubmit() {
|
||||
sendEvent(hardwareSubmitEventName, [:])
|
||||
}
|
||||
}
|
||||
|
||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return super.keyCommands
|
||||
}
|
||||
|
||||
let command = UIKeyCommand(
|
||||
input: "\r",
|
||||
modifierFlags: [],
|
||||
action: #selector(handleHardwareKeyboardSubmit(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return (super.keyCommands ?? []) + [command]
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
return
|
||||
}
|
||||
activeModule?.emitHardwareKeyboardSubmit()
|
||||
}
|
||||
|
||||
private func canSubmitCurrentTextInput() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||
return false
|
||||
}
|
||||
guard let textInput = responder as? UITextInput else {
|
||||
return false
|
||||
}
|
||||
return textInput.markedTextRange == nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIResponder {
|
||||
private static weak var currentFirstResponder: UIResponder?
|
||||
|
||||
static var paseoCurrentFirstResponder: UIResponder? {
|
||||
currentFirstResponder = nil
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(captureCurrentFirstResponder(_:)),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
return currentFirstResponder
|
||||
}
|
||||
|
||||
@objc
|
||||
private func captureCurrentFirstResponder(_ sender: Any?) {
|
||||
UIResponder.currentFirstResponder = self
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "paseo-hardware-keyboard",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.71",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "cross-env APP_VARIANT=development expo start",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
@@ -21,8 +20,7 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:real": "playwright test --project=real-provider",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||
@@ -35,7 +33,7 @@
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "*",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
@@ -43,15 +41,15 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||
"@xterm/addon-image": "^0.10.0-beta.213",
|
||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.9.0",
|
||||
"@xterm/addon-ligatures": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
"expo-asset": "~12.0.12",
|
||||
@@ -65,7 +63,6 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
@@ -77,7 +74,6 @@
|
||||
"expo-updates": "~29.0.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -88,12 +84,12 @@
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-nitro-modules": "0.33.8",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-unistyles": "^3.0.15",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
@@ -105,13 +101,11 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
@@ -120,7 +114,7 @@
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.6",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.75.0",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
|
||||
@@ -27,12 +27,6 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Chrome",
|
||||
testIgnore: ["**/*.real.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "real-provider",
|
||||
testMatch: ["**/*.real.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -3,17 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="theme-color" content="#181B1A" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Paseo" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
@@ -25,8 +18,6 @@
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
/* Prevent white flash before React mounts */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Paseo",
|
||||
"short_name": "Paseo",
|
||||
"description": "Monitor and control local AI coding agents from anywhere.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#181B1A",
|
||||
"background_color": "#181B1A",
|
||||
"categories": ["developer", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,89 +0,0 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.js"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
build.onResolve({ filter: /^@server\// }, async (args) => ({
|
||||
path: await resolveTsPath(
|
||||
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
loader: {
|
||||
".css": "text",
|
||||
},
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const js = result.outputFiles[0]?.text;
|
||||
if (!js) {
|
||||
throw new Error("terminal webview bundle produced no JavaScript");
|
||||
}
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { layoutStream, type StreamLayout, type StreamLayoutItem } from "./layout";
|
||||
|
||||
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,
|
||||
block?: { groupId: string; index: number },
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: timestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "Shell",
|
||||
arguments: "echo hi",
|
||||
result: null,
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought" }> {
|
||||
return {
|
||||
kind: "thought",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
status: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||
const timing = {
|
||||
startedAt: timestamp(1),
|
||||
completedAt: timestamp(9),
|
||||
durationMs: 8000,
|
||||
};
|
||||
return new Map(ids.map((id) => [id, timing]));
|
||||
}
|
||||
|
||||
function strategyFor(platform: "web" | "android"): StreamStrategy {
|
||||
return resolveStreamRenderStrategy({
|
||||
platform,
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
}
|
||||
|
||||
function layoutFor(input: {
|
||||
platform: "web" | "android";
|
||||
agentStatus?: string;
|
||||
tail: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
timingIds?: string[];
|
||||
}): StreamLayout {
|
||||
const strategy = strategyFor(input.platform);
|
||||
return layoutStream({
|
||||
strategy,
|
||||
agentStatus: input.agentStatus ?? "idle",
|
||||
history: orderTailForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamItems: input.tail,
|
||||
}),
|
||||
liveHead: orderHeadForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
function footerOwners(layout: StreamLayout): string[] {
|
||||
const owners = [
|
||||
...layout.history.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
|
||||
...layout.liveHead.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
return owners;
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
);
|
||||
if (!item) {
|
||||
throw new Error(`Missing layout item ${id}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(footerOwners(layout)).toEqual([headBlock.id]);
|
||||
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
|
||||
expect(findLayoutItem(layout, historyBlock.id).completedFooter).toBeNull();
|
||||
});
|
||||
|
||||
it("does not duplicate footers when a web assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(footerOwners(layout)).toEqual([headBlock.id]);
|
||||
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
|
||||
expect(findLayoutItem(layout, headBlock.id).aboveItem?.id).toBe(historyBlock.id);
|
||||
});
|
||||
|
||||
it("keeps the completed footer visually after the assistant after a native user reply", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
const assistantRow = findLayoutItem(layout, assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantRow.belowItem?.id).toBe("u2");
|
||||
expect(assistantRow.frameOrder).toBe("footer-then-content");
|
||||
});
|
||||
|
||||
it("keeps forward stream content before its completed footer", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
const assistantRow = findLayoutItem(layout, assistant.id);
|
||||
|
||||
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantRow.frameOrder).toBe("content-then-footer");
|
||||
});
|
||||
|
||||
it("compacts assistant block spacing across the history and live-head boundary", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
|
||||
});
|
||||
|
||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), shell, thinking, assistantMessage("a1", 4)],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, shell.id).toolSequence).toBe("first");
|
||||
expect(findLayoutItem(layout, thinking.id).toolSequence).toBe("last");
|
||||
});
|
||||
|
||||
it("keeps bottom and inline footer ownership mutually exclusive", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
||||
|
||||
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
|
||||
|
||||
export interface TurnFooterHost {
|
||||
itemId: string;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export interface StreamLayoutItem {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
gapBelow: number;
|
||||
assistantSpacing: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
completedFooter: TurnFooterHost | null;
|
||||
toolSequence: StreamToolSequence;
|
||||
isFirstInUserGroup: boolean;
|
||||
isLastInUserGroup: boolean;
|
||||
isLastInToolSequence: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
}
|
||||
|
||||
export interface StreamLayout {
|
||||
history: StreamLayoutItem[];
|
||||
liveHead: StreamLayoutItem[];
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
historyToHeadGap: number;
|
||||
}
|
||||
|
||||
export interface StreamLayoutInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (startIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: startIndex,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
}): 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): boolean {
|
||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
}
|
||||
|
||||
function getToolSequence(input: {
|
||||
item: StreamItem;
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
}): StreamToolSequence {
|
||||
if (!isToolSequenceItem(input.item)) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const hasAbove = isToolSequenceItem(input.aboveItem);
|
||||
const hasBelow = isToolSequenceItem(input.belowItem);
|
||||
if (hasAbove && hasBelow) {
|
||||
return "middle";
|
||||
}
|
||||
if (hasAbove) {
|
||||
return "last";
|
||||
}
|
||||
if (hasBelow) {
|
||||
return "first";
|
||||
}
|
||||
return "single";
|
||||
}
|
||||
|
||||
function getSegmentNeighbor(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
relation: "above" | "below";
|
||||
boundaryIndex: number | null;
|
||||
boundaryItem: StreamItem | null;
|
||||
}): StreamItem | null {
|
||||
const neighbor = input.strategy.getNeighborItem(input.items, input.index, input.relation);
|
||||
if (neighbor) {
|
||||
return neighbor;
|
||||
}
|
||||
if (input.index === input.boundaryIndex) {
|
||||
return input.boundaryItem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
return input.items.map((item, index) => {
|
||||
const aboveItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "above",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryAboveItem,
|
||||
});
|
||||
const belowItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
items: input.items,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||
assistantSpacing,
|
||||
completedFooter,
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
|
||||
frameOrder: input.frameOrder,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
|
||||
const historyBoundaryItem =
|
||||
historyBoundaryIndex === null ? null : (input.history[historyBoundaryIndex] ?? null);
|
||||
const liveHeadBoundaryItem =
|
||||
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
|
||||
const frameOrder = input.strategy.getFrameChildOrder();
|
||||
const history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
});
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
liveHead,
|
||||
auxiliaryTurnFooter,
|
||||
historyToHeadGap: getGapBetweenStreamItems(historyBoundaryItem, liveHeadBoundaryItem),
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
||||
</TurnFooterRow>
|
||||
);
|
||||
}
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={strategy}
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
strategy,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
<CompletedTurnFooter
|
||||
strategy={strategy}
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
});
|
||||
|
||||
const WorkingIndicator = memo(function WorkingIndicator({
|
||||
inFlightTurnStartedAt = null,
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingLoader}>
|
||||
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
|
||||
</View>
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
startedAt={inFlightTurnStartedAt}
|
||||
style={stylesheet.workingElapsed}
|
||||
testID="turn-working-elapsed"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
|
||||
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletedTurnFooter({
|
||||
strategy,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items,
|
||||
startIndex,
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
|
||||
return <View style={rowStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
turnFooterRow: {
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
minHeight: 24,
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
turnFooterContent: {
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
workingElapsed: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
workingLoader: {
|
||||
marginLeft: -2,
|
||||
},
|
||||
}));
|
||||
@@ -30,7 +30,6 @@ import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
@@ -57,7 +56,6 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -377,8 +375,6 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||
const flexStyle = { flex: 1 } as const;
|
||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
@@ -410,7 +406,6 @@ function AppContainer({
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
useCompactWebViewportZoomLock(isCompactLayout);
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const pathname = usePathname();
|
||||
const activeServerId = useMemo(
|
||||
@@ -458,7 +453,6 @@ function AppContainer({
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
@@ -504,7 +498,6 @@ function MobileGestureWrapper({
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
@@ -523,7 +516,6 @@ function MobileGestureWrapper({
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch) {
|
||||
touchStartX.value = touch.absoluteX;
|
||||
touchStartY.value = touch.absoluteY;
|
||||
}
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
@@ -531,31 +523,13 @@ function MobileGestureWrapper({
|
||||
if (!touch || event.numberOfTouches !== 1) return;
|
||||
|
||||
const deltaX = touch.absoluteX - touchStartX.value;
|
||||
const deltaY = touch.absoluteY - touchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX > 15 && absDeltaX > absDeltaY) {
|
||||
if (deltaX > 15) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
@@ -597,12 +571,11 @@ function MobileGestureWrapper({
|
||||
openGestureRef,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,10 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
}>();
|
||||
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
@@ -22,7 +16,6 @@ export default function HostNewWorkspaceRoute() {
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,24 +171,14 @@ describe("resolveStartupRedirectRoute", () => {
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
|
||||
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when another host is first online", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
expect(route).toBe("/h/server-2");
|
||||
});
|
||||
|
||||
it("redirects to the host root when no persisted workspace exists", () => {
|
||||
|
||||
@@ -79,7 +79,11 @@ export function resolveStartupWorkspaceSelection(
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (!input.workspaceSelection) {
|
||||
if (
|
||||
!input.anyOnlineHostServerId ||
|
||||
!input.workspaceSelection ||
|
||||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
const { redirectMock, state } = vi.hoisted(() => {
|
||||
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
const hoistedState = {
|
||||
pathname: "/",
|
||||
bootstrapState: {
|
||||
@@ -18,11 +18,11 @@ const { redirectMock, state } = vi.hoisted(() => {
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
return {
|
||||
navigateToWorkspaceMock: vi.fn(),
|
||||
redirectMock: vi.fn(),
|
||||
state: hoistedState,
|
||||
};
|
||||
@@ -50,8 +50,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
@@ -68,8 +68,8 @@ describe("Index route startup navigation", () => {
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
redirectMock.mockReset();
|
||||
|
||||
container = document.createElement("div");
|
||||
@@ -98,33 +98,26 @@ describe("Index route startup navigation", () => {
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
it("navigates to the host root when the persisted workspace targets a different server", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
resolveStartupWorkspaceSelection,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
|
||||
@@ -19,33 +18,34 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelection = useActiveWorkspaceSelection();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!startupWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}, [pathname, startupWorkspaceSelection]);
|
||||
|
||||
if (startupWorkspaceSelection) {
|
||||
return (
|
||||
<Redirect
|
||||
href={buildHostWorkspaceRoute(
|
||||
startupWorkspaceSelection.serverId,
|
||||
startupWorkspaceSelection.workspaceId,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
}
|
||||
|
||||
if (redirectRoute) {
|
||||
|
||||
@@ -27,8 +27,6 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
||||
deepagents:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
||||
"deepseek-tui":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
|
||||
dimcode:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
||||
dirac:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="currentColor" opacity="0.18"/>
|
||||
<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="'Noto Serif SC', serif" font-weight="700" font-size="20" fill="currentColor">深</text>
|
||||
<rect x="0" y="29" width="32" height="3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
@@ -1,16 +0,0 @@
|
||||
export {
|
||||
AssistantInlineCodePathLink,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkActions } from "./use-file-link";
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onAuxPress();
|
||||
});
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
onHoverIn();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
|
||||
if (isNative) {
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
<Text accessibilityRole="link" onPress={onPress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
href={source.href}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
);
|
||||
|
||||
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
|
||||
interface AssistantMarkdownCodeLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownCodeLink({
|
||||
source,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
children,
|
||||
}: AssistantMarkdownCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, codeInlineStyle, linkStyle],
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink source={source} style={style}>
|
||||
{children}
|
||||
</AssistantMarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
}
|
||||
|
||||
export function AssistantInlineCodePathLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
}: AssistantInlineCodePathLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantMarkdownCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
||||
// the tooltip wrapper from breaking inline link flow.
|
||||
display: "inline-flex" as ViewStyle["display"],
|
||||
};
|
||||
|
||||
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
||||
|
||||
function FileLinkHoverTooltip({
|
||||
filePath,
|
||||
children,
|
||||
}: {
|
||||
filePath: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!isWeb) {
|
||||
return children;
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
||||
</TooltipTrigger>
|
||||
{filePath ? (
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
<View style={styles.tooltipHintRow}>
|
||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||
<Text selectable={false} style={styles.tooltipHintText}>
|
||||
click for side pane
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const LINK_ANCHOR_STYLE: CSSProperties = {
|
||||
display: "contents",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tooltipBody: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipHintRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipHintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -1,424 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
parseAssistantFileLink,
|
||||
parseFileProtocolUrl,
|
||||
parseInlinePathToken,
|
||||
} from "./parse";
|
||||
|
||||
describe("parseInlinePathToken", () => {
|
||||
it("returns null for plain paths without a line number", () => {
|
||||
expect(parseInlinePathToken("src/app.ts")).toBeNull();
|
||||
expect(parseInlinePathToken("README.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses filename:line", () => {
|
||||
expect(parseInlinePathToken("src/app.ts:12")).toEqual({
|
||||
raw: "src/app.ts:12",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename:lineStart-lineEnd", () => {
|
||||
expect(parseInlinePathToken("src/app.ts:12-20")).toEqual({
|
||||
raw: "src/app.ts:12-20",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename:line:column as a line target", () => {
|
||||
expect(parseInlinePathToken("src/app.ts:12:4")).toEqual({
|
||||
raw: "src/app.ts:12:4",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename(line,column) as a line target", () => {
|
||||
expect(parseInlinePathToken("src/app.ts(12,4)")).toEqual({
|
||||
raw: "src/app.ts(12,4)",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename lines lineStart-lineEnd", () => {
|
||||
expect(parseInlinePathToken("src/app.ts lines 12-20")).toEqual({
|
||||
raw: "src/app.ts lines 12-20",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects range-only :line tokens", () => {
|
||||
expect(parseInlinePathToken(":12")).toBeNull();
|
||||
expect(parseInlinePathToken(":12-20")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileProtocolUrl", () => {
|
||||
it("parses file URLs with line fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx#L81",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 81,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file URLs with line-column fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81C5-L83C2")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx#L81C5-L83C2",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 81,
|
||||
lineEnd: 83,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file URLs without line fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses windows file URLs and line ranges", () => {
|
||||
expect(parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")).toEqual({
|
||||
raw: "file:///C:/Users/test/project/src/app.tsx#L12-L20",
|
||||
path: "C:/Users/test/project/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-file URLs and invalid ranges", () => {
|
||||
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyAssistantFileLink", () => {
|
||||
it("keeps explicit external URLs out of file parsing", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink("http://dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "external",
|
||||
raw: "http://dumm.md",
|
||||
});
|
||||
expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
|
||||
kind: "external",
|
||||
raw: "mailto:test@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies bare workspace candidates separately from direct relative files", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink("dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ambiguousFileCandidate",
|
||||
target: {
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyAssistantFileLink("message-renderer.tsx", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ambiguousFileCandidate",
|
||||
target: {
|
||||
raw: "message-renderer.tsx",
|
||||
path: "/Users/test/project/message-renderer.tsx",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyAssistantFileLink("src/components/message.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "directFile",
|
||||
target: {
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not classify normal bare domains as file candidates", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink("google.com", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyAssistantFileLink("example.com", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyAssistantFileLink("openai.com/path", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not classify plain inline code words or identifiers as file candidates", () => {
|
||||
for (const value of [
|
||||
"main",
|
||||
"origin/main",
|
||||
"1f7fc232b",
|
||||
"25994904967",
|
||||
"babysit main 1f7fc232b",
|
||||
]) {
|
||||
expect(
|
||||
classifyAssistantFileLink(value, {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not classify shell commands containing path arguments as file candidates", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink(
|
||||
"npm run lint -- packages/app/src/stores/workspace-layout-actions.ts packages/app/src/stores/workspace-layout-store.ts packages/app/src/screens/workspace/workspace-screen.tsx",
|
||||
{
|
||||
workspaceRoot: "/Users/test/project",
|
||||
},
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAssistantFileLink", () => {
|
||||
it("resolves bare markdown filenames against the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves bare source filenames with line suffixes against the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("file.ts:12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects bare domains and domain-like paths", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("google.com", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseAssistantFileLink("google.com:80", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseAssistantFileLink("openai.com/path", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves relative paths against the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("src/components/message.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/Users/test/project/src/app.tsx#L33",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute POSIX hrefs with VS Code-style line suffixes inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx:33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/Users/test/project/src/app.tsx:33",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute Windows hrefs inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
||||
workspaceRoot: "C:/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/src/app.tsx#L12-L20",
|
||||
path: "C:/repo/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute Windows hrefs with VS Code-style line suffixes inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/src/app.tsx:12-20", {
|
||||
workspaceRoot: "C:/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/src/app.tsx:12-20",
|
||||
path: "C:/repo/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows file URLs even when they are outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("file:///tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "file:///tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute hrefs outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects external URLs", () => {
|
||||
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
|
||||
expect(
|
||||
parseAssistantFileLink("http://dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid line fragments", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw when the input contains a literal '%' that is not a valid percent-escape", () => {
|
||||
// Regressions for tool output strings like ping's "100% packet loss",
|
||||
// Windows "%PATH%" references, and percentages such as "0% off".
|
||||
// decodeURIComponent throws URIError on these; the parser must swallow
|
||||
// it and return null rather than crash the renderer.
|
||||
const cases = [
|
||||
"/tmp/100% packet loss",
|
||||
"/Users/test/project/0% off",
|
||||
"/var/log/%PATH%/x.log",
|
||||
"file:///tmp/100% packet loss",
|
||||
];
|
||||
for (const value of cases) {
|
||||
expect(() =>
|
||||
parseAssistantFileLink(value, { workspaceRoot: "/Users/test/project" }),
|
||||
).not.toThrow();
|
||||
}
|
||||
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
it("keeps relative file paths as file targets", () => {
|
||||
expect(normalizeInlinePathTarget("packages/app/src/components/message.tsx")).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves absolute paths under cwd back to workspace-relative paths", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget(
|
||||
"/Users/test/project/packages/app/src/components/message.tsx",
|
||||
"/Users/test/project",
|
||||
),
|
||||
).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside cwd as absolute file targets", () => {
|
||||
expect(normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")).toEqual({
|
||||
directory: "/tmp",
|
||||
file: "/tmp/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cwd itself as the workspace root directory", () => {
|
||||
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
|
||||
directory: ".",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps trailing-slash paths as directories", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project"),
|
||||
).toEqual({
|
||||
directory: "packages/app",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import React from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
export interface AssistantFileLinkDaemonClient {
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverConfig {
|
||||
client?: AssistantFileLinkDaemonClient | null;
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
toast?: ToastApi | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverContextValue {
|
||||
configRef: MutableRefObject<AssistantFileLinkResolverConfig>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
const AssistantFileLinkResolverContext =
|
||||
createContext<AssistantFileLinkResolverContextValue | null>(null);
|
||||
|
||||
export function AssistantFileLinkResolverProvider({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile,
|
||||
toast,
|
||||
children,
|
||||
}: AssistantFileLinkResolverProviderProps) {
|
||||
const configRef = useRef<AssistantFileLinkResolverConfig>({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile,
|
||||
toast,
|
||||
});
|
||||
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
|
||||
|
||||
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
|
||||
const activeClient = configRef.current.client;
|
||||
if (!activeClient) {
|
||||
return { entries: [], error: null };
|
||||
}
|
||||
|
||||
const result = await activeClient.getDirectorySuggestions(input);
|
||||
return { entries: result.entries, error: result.error };
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AssistantFileLinkResolverContextValue>(
|
||||
() => ({ configRef, getDirectorySuggestions }),
|
||||
[getDirectorySuggestions],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantFileLinkResolverContext.Provider value={value}>
|
||||
{children}
|
||||
</AssistantFileLinkResolverContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
|
||||
const context = useContext(AssistantFileLinkResolverContext);
|
||||
if (!context) {
|
||||
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type { AssistantFileLinkContext };
|
||||
@@ -1,251 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
type DirectorySuggestionResult,
|
||||
type GetDirectorySuggestions,
|
||||
} from "./resolver";
|
||||
|
||||
const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
searches: Array<{
|
||||
query: string;
|
||||
cwd: string;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}>;
|
||||
} {
|
||||
const searches: Array<{
|
||||
query: string;
|
||||
cwd: string;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}> = [];
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
|
||||
async (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => {
|
||||
searches.push({
|
||||
query: input.query,
|
||||
cwd: input.cwd,
|
||||
matchMode: input.matchMode,
|
||||
limit: input.limit,
|
||||
});
|
||||
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||
},
|
||||
);
|
||||
return { getDirectorySuggestions, searches };
|
||||
}
|
||||
|
||||
describe("classifyForResolution", () => {
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves line ranges on direct workspace files", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "src/components/message.tsx:33-40",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
|
||||
CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: "file.ts",
|
||||
token: "file.ts:12",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit external URLs external", () => {
|
||||
const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "external", url: "http://dumm.md" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "external", url: "http://google.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ignored for non-file-looking content", () => {
|
||||
const result = classifyForResolution({ href: "" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDaemonResolution", () => {
|
||||
it("resolves daemon suggestions into workspace file targets", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await fetchDaemonResolution({
|
||||
ambiguousQuery: "file.ts",
|
||||
token: "file.ts:12",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "file.ts",
|
||||
cwd: "/Users/test/project",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/packages/app/src/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a typed unresolved error when the daemon finds no match", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: "src/file.ts",
|
||||
token: "src/file.ts",
|
||||
target: {
|
||||
raw: "src/file.ts",
|
||||
path: "/Users/test/project/src/file.ts",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
|
||||
});
|
||||
|
||||
it("throws a typed unresolved error when the daemon throws", async () => {
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: "dumm.md",
|
||||
token: "dumm.md",
|
||||
target: {
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
}),
|
||||
).toBe("dumm.md");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://google.com",
|
||||
text: "google.com",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
}),
|
||||
).toBe("http://google.com");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "",
|
||||
sourceInfo: "",
|
||||
}),
|
||||
).toBe("http://dumm.md");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "workspace-git-service.ts:1553",
|
||||
text: "workspace-git-service.ts:1553",
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
).toBe("workspace-git-service.ts:1553");
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import {
|
||||
classifyAssistantFileLink,
|
||||
isFileLookingAssistantToken,
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
text?: string;
|
||||
markup?: string;
|
||||
sourceInfo?: string;
|
||||
sourceType?: "inline-code";
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionEntry {
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionResult {
|
||||
entries: DirectorySuggestionEntry[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type GetDirectorySuggestions = (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
|
||||
export type ResolvedAssistantFileLink =
|
||||
| { kind: "external"; url: string }
|
||||
| { kind: "file"; target: InlinePathTarget }
|
||||
| { kind: "ignored" };
|
||||
|
||||
export type AssistantFileLinkResolution =
|
||||
| { kind: "resolved"; value: ResolvedAssistantFileLink }
|
||||
| {
|
||||
kind: "needsLookup";
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
};
|
||||
|
||||
export interface FetchDaemonResolutionInput {
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
export class UnresolvedFileLinkError extends Error {
|
||||
constructor(readonly token: string) {
|
||||
super(`No file found for ${token}`);
|
||||
this.name = "UnresolvedFileLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDaemonResolution({
|
||||
ambiguousQuery,
|
||||
token,
|
||||
target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
|
||||
const trimmedRoot = workspaceRoot?.trim();
|
||||
if (!trimmedRoot) {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
let suggestions: DirectorySuggestionResult;
|
||||
try {
|
||||
suggestions = await getDirectorySuggestions({
|
||||
query: ambiguousQuery,
|
||||
cwd: trimmedRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
} catch {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
if (!match || suggestions.error) {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
return {
|
||||
...target,
|
||||
path: joinWorkspacePath(trimmedRoot, match.path),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
): AssistantFileLinkResolution {
|
||||
const token = getAssistantFileLinkToken(source).trim();
|
||||
if (!token) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
if (classification.kind === "external") {
|
||||
return { kind: "resolved", value: { kind: "external", url: classification.raw } };
|
||||
}
|
||||
if (
|
||||
classification.kind === "directFile" &&
|
||||
!shouldResolveDirectFileThroughSuggestions({
|
||||
context,
|
||||
source,
|
||||
token,
|
||||
target: classification.target,
|
||||
})
|
||||
) {
|
||||
return { kind: "resolved", value: { kind: "file", target: classification.target } };
|
||||
}
|
||||
|
||||
const workspaceRoot = context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot),
|
||||
token,
|
||||
target: classification.target,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
||||
const text = source.text?.trim();
|
||||
if (text && isFileLookingAssistantToken(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return source.href;
|
||||
}
|
||||
|
||||
export function getAmbiguousSuggestionQuery(
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot: string,
|
||||
): string {
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
|
||||
const lastSlash = normalizedPath.lastIndexOf("/");
|
||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||
}
|
||||
|
||||
export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
}): boolean {
|
||||
if (input.source.sourceType !== "inline-code") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAbsoluteInlineCodeToken(input.token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceRoot = input.context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = input.target.path.replace(/\\/g, "/");
|
||||
return normalizedPath.startsWith(`${normalizedRoot}/`);
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
return source.markup === "linkify" || source.sourceInfo === "auto";
|
||||
}
|
||||
|
||||
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
||||
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return root ? `${root}/${child}` : child;
|
||||
}
|
||||
|
||||
export type { AssistantFileLinkClassification };
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import { AssistantFileLinkResolverProvider } from "./provider";
|
||||
import type { DirectorySuggestionResult } from "./resolver";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const SOURCE = {
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "linkify",
|
||||
};
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
interface TestClient {
|
||||
getDirectorySuggestions: (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
}
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createToast(): ToastApi {
|
||||
return {
|
||||
show: vi.fn<ToastApi["show"]>(),
|
||||
copied: vi.fn<ToastApi["copied"]>(),
|
||||
error: vi.fn<ToastApi["error"]>(),
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; toast?: ToastApi }) {
|
||||
const queryClient = createQueryClient();
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
const openWorkspaceFile = useCallback(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
input.openedFiles.push({ target, disposition });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={input.client}
|
||||
serverId="server-1"
|
||||
workspaceRoot="/Users/test/project"
|
||||
onOpenWorkspaceFile={openWorkspaceFile}
|
||||
toast={input.toast}
|
||||
>
|
||||
{children}
|
||||
</AssistantFileLinkResolverProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFileLink", () => {
|
||||
it("returns the same object across no-op parent rerenders", () => {
|
||||
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const queryClient = createQueryClient();
|
||||
const Provider = AssistantFileLinkResolverProvider as React.ComponentType<
|
||||
Omit<React.ComponentProps<typeof AssistantFileLinkResolverProvider>, "children"> & {
|
||||
children?: ReactNode;
|
||||
}
|
||||
>;
|
||||
|
||||
function ChurningProviderWrapper({ children }: { children: ReactNode }) {
|
||||
return React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(
|
||||
Provider,
|
||||
{
|
||||
client: { getDirectorySuggestions },
|
||||
serverId: "server-1",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
onOpenWorkspaceFile: () => {},
|
||||
toast: createToast(),
|
||||
},
|
||||
children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), {
|
||||
wrapper: ChurningProviderWrapper,
|
||||
});
|
||||
const first = result.current;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(first);
|
||||
expect(result.current.onHoverIn).toBe(first.onHoverIn);
|
||||
expect(result.current.onPress).toBe(first.onPress);
|
||||
expect(result.current.onAuxPress).toBe(first.onAuxPress);
|
||||
expect(result.current.open).toBe(first.open);
|
||||
});
|
||||
|
||||
it("does not cache unresolved lookups forever", async () => {
|
||||
const getDirectorySuggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const toast = createToast();
|
||||
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
toast,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(openedFiles).toEqual([
|
||||
{
|
||||
target: {
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("click retries after hover prefetch fails", async () => {
|
||||
const getDirectorySuggestions = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onHoverIn();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(openedFiles).toHaveLength(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("dedupes two links pointing at the same source", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const getDirectorySuggestions = vi.fn(() => deferred.promise);
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
first: useFileLink(SOURCE),
|
||||
second: useFileLink(SOURCE),
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.first.onHoverIn();
|
||||
result.current.second.onHoverIn();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
await waitFor(() => {
|
||||
expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||
expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||
});
|
||||
});
|
||||
|
||||
it("hover then click uses the prefetched result", async () => {
|
||||
const getDirectorySuggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
|
||||
);
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||
wrapper: createWrapper({
|
||||
client: { getDirectorySuggestions },
|
||||
openedFiles,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onHoverIn();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPress();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(openedFiles).toHaveLength(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not open a stale result after the workspace changes", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const getDirectorySuggestions = vi.fn(() => deferred.promise);
|
||||
const openedFiles: OpenedFile[] = [];
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project");
|
||||
const client = useMemo(() => ({ getDirectorySuggestions }), []);
|
||||
const openWorkspaceFile = useCallback(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
openedFiles.push({ target, disposition });
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenWorkspaceFile={openWorkspaceFile}
|
||||
>
|
||||
<WorkspaceSwitchContext.Provider value={setWorkspaceRoot}>
|
||||
{children}
|
||||
</WorkspaceSwitchContext.Provider>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
link: useFileLink(SOURCE),
|
||||
setWorkspaceRoot: React.useContext(WorkspaceSwitchContext),
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.link.onPress();
|
||||
});
|
||||
act(() => {
|
||||
result.current.setWorkspaceRoot("/Users/test/other");
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(openedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {});
|
||||
@@ -1,347 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import {
|
||||
useAssistantFileLinkResolverContext,
|
||||
type AssistantFileLinkResolverContextValue,
|
||||
} from "./provider";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkResolution,
|
||||
type AssistantFileLinkSource,
|
||||
} from "./resolver";
|
||||
|
||||
export interface UseFileLinkResult {
|
||||
target: InlinePathTarget | null;
|
||||
onHoverIn: () => void;
|
||||
onPress: () => void;
|
||||
onAuxPress: () => void;
|
||||
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkActions {
|
||||
open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void;
|
||||
canOpen(source: AssistantFileLinkSource): boolean;
|
||||
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||
}
|
||||
|
||||
type AssistantFileLinkQueryKey = readonly [
|
||||
"assistantFileLink",
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
];
|
||||
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const queryClient = useQueryClient();
|
||||
const stableSource = useStableSource(source);
|
||||
const activeConfig = context.configRef.current;
|
||||
const workspaceRoot = activeConfig.workspaceRoot;
|
||||
const serverId = activeConfig.serverId;
|
||||
const resolution = useMemo(
|
||||
() =>
|
||||
classifyForResolution(stableSource, {
|
||||
workspaceRoot,
|
||||
}),
|
||||
[stableSource, workspaceRoot],
|
||||
);
|
||||
const queryKey = useMemo(
|
||||
() =>
|
||||
resolution.kind === "needsLookup"
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => {
|
||||
if (resolution.kind !== "needsLookup") {
|
||||
throw new Error("Assistant file link lookup requested for a sync link.");
|
||||
}
|
||||
return fetchDaemonResolution({
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
target: resolution.target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions: context.getDirectorySuggestions,
|
||||
});
|
||||
},
|
||||
enabled: false,
|
||||
retry: 0,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const open = useStableEvent(
|
||||
(nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
openAssistantFileLink({
|
||||
source: nextSource,
|
||||
disposition,
|
||||
context,
|
||||
queryClient,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const onHoverIn = useStableEvent(() => {
|
||||
if (resolution.kind !== "needsLookup") {
|
||||
return;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
target: resolution.target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions: context.getDirectorySuggestions,
|
||||
}),
|
||||
retry: 0,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
});
|
||||
|
||||
const onPress = useStableEvent(() => {
|
||||
open(stableSource, "main");
|
||||
});
|
||||
const onAuxPress = useStableEvent(() => {
|
||||
open(stableSource, "side");
|
||||
});
|
||||
|
||||
const target = useMemo(() => {
|
||||
if (resolution.kind === "resolved") {
|
||||
return resolution.value.kind === "file" ? resolution.value.target : null;
|
||||
}
|
||||
return query.data ?? null;
|
||||
}, [query.data, resolution]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
|
||||
[target, onHoverIn, onPress, onAuxPress, open],
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const actionLink = useFileLink(ACTION_LINK_SOURCE);
|
||||
|
||||
const open = useStableEvent(
|
||||
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
actionLink.open(source, disposition);
|
||||
},
|
||||
);
|
||||
const canOpen = useCallback(
|
||||
(source: AssistantFileLinkSource) =>
|
||||
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
|
||||
[context.configRef],
|
||||
);
|
||||
const canResolveFile = useCallback(
|
||||
(source: AssistantFileLinkSource) =>
|
||||
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
|
||||
[context.configRef],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]);
|
||||
}
|
||||
|
||||
function openAssistantFileLink(input: {
|
||||
source: AssistantFileLinkSource;
|
||||
disposition: OpenFileDisposition;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
}): void {
|
||||
const capturedConfig = input.context.configRef.current;
|
||||
const capturedResolution = classifyForResolution(input.source, {
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
});
|
||||
|
||||
if (capturedResolution.kind === "resolved") {
|
||||
void dispatchResolvedLink({
|
||||
resolution: capturedResolution,
|
||||
disposition: input.disposition,
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const target = await input.queryClient.fetchQuery({
|
||||
queryKey: capturedQueryKey,
|
||||
queryFn: () =>
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
token: capturedResolution.token,
|
||||
target: capturedResolution.target,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
getDirectorySuggestions: input.context.getDirectorySuggestions,
|
||||
}),
|
||||
retry: 0,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
await dispatchFileTarget({
|
||||
target,
|
||||
disposition: input.disposition,
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatchUnresolvedError({
|
||||
error,
|
||||
fallbackToken: capturedResolution.token,
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
}
|
||||
|
||||
function canOpenAssistantFileLink(
|
||||
source: AssistantFileLinkSource,
|
||||
workspaceRoot: string | undefined,
|
||||
): boolean {
|
||||
const resolution = classifyForResolution(source, { workspaceRoot });
|
||||
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
|
||||
}
|
||||
|
||||
function canResolveAssistantFileLinkToFile(
|
||||
source: AssistantFileLinkSource,
|
||||
workspaceRoot: string | undefined,
|
||||
): boolean {
|
||||
const resolution = classifyForResolution(source, { workspaceRoot });
|
||||
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
|
||||
}
|
||||
|
||||
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
|
||||
const { href, text, markup, sourceInfo, sourceType } = source;
|
||||
return useMemo(
|
||||
() => ({ href, text, markup, sourceInfo, sourceType }),
|
||||
[href, text, markup, sourceInfo, sourceType],
|
||||
);
|
||||
}
|
||||
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
ambiguousQuery: string;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
input.ambiguousQuery,
|
||||
];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
resolution: Extract<AssistantFileLinkResolution, { kind: "resolved" }>;
|
||||
disposition: OpenFileDisposition;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const { value } = input.resolution;
|
||||
if (value.kind === "file") {
|
||||
await dispatchFileTarget({
|
||||
target: value.target,
|
||||
disposition: input.disposition,
|
||||
capturedServerId: input.capturedServerId,
|
||||
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value.kind === "external") {
|
||||
await dispatchExternalUrl({
|
||||
url: value.url,
|
||||
capturedServerId: input.capturedServerId,
|
||||
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
|
||||
context: input.context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchFileTarget(input: {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const current = input.context.configRef.current;
|
||||
if (
|
||||
current.serverId !== input.capturedServerId ||
|
||||
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
current.onOpenWorkspaceFile?.(input.target, input.disposition);
|
||||
}
|
||||
|
||||
async function dispatchExternalUrl(input: {
|
||||
url: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const current = input.context.configRef.current;
|
||||
if (
|
||||
current.serverId !== input.capturedServerId ||
|
||||
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await openExternalUrl(input.url);
|
||||
}
|
||||
|
||||
async function dispatchUnresolvedError(input: {
|
||||
error: unknown;
|
||||
fallbackToken: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
}) {
|
||||
const current = input.context.configRef.current;
|
||||
if (
|
||||
current.serverId !== input.capturedServerId ||
|
||||
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const token =
|
||||
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
|
||||
current.toast?.show(`No file found for ${token}`, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
}
|
||||
|
||||
const ACTION_LINK_SOURCE: AssistantFileLinkSource = {
|
||||
href: "",
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user