mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 | ||
|
|
391cf75ece | ||
|
|
7ee2d6fd7e | ||
|
|
b1d11fccd5 | ||
|
|
05f8dda1e3 | ||
|
|
9968f2ba50 | ||
|
|
0ecf2fb8da | ||
|
|
b3eb77981a | ||
|
|
3e28b9b59e | ||
|
|
42d1a2e4e3 | ||
|
|
e98d8c7a36 | ||
|
|
9c220c2451 | ||
|
|
b28f291493 | ||
|
|
f226222638 | ||
|
|
6ce4e3f507 | ||
|
|
8d3a43e05d | ||
|
|
7cb0ccba60 | ||
|
|
f1a8e9a563 | ||
|
|
75b11ac93e | ||
|
|
8680bc2236 | ||
|
|
e67e25628b | ||
|
|
860fe92fdd | ||
|
|
ab62a023fd | ||
|
|
cc21262130 | ||
|
|
2a226b3872 | ||
|
|
a13fa4e614 | ||
|
|
ccd2bb2bf3 | ||
|
|
570e652496 | ||
|
|
6d55565d19 | ||
|
|
fec723c775 | ||
|
|
1bf50349a5 | ||
|
|
cda1ba43cc | ||
|
|
f22865df62 | ||
|
|
33ffeb4d42 | ||
|
|
6781521c8e | ||
|
|
4ca89f21f9 | ||
|
|
9430a897e8 | ||
|
|
b0088303fd | ||
|
|
2ba3ad53ab | ||
|
|
b2714ccd89 | ||
|
|
82993c2197 | ||
|
|
742d47fe71 | ||
|
|
441b935b73 | ||
|
|
b2ad903005 | ||
|
|
554e525186 | ||
|
|
71ce96b60f | ||
|
|
d9615c1e98 | ||
|
|
6fea8305a7 | ||
|
|
172de4fbc2 | ||
|
|
adc0c01782 | ||
|
|
3eed8c31a2 | ||
|
|
58fce6622b | ||
|
|
27f1f1d207 | ||
|
|
807d0d6d69 | ||
|
|
6513b56571 |
@@ -1,11 +1,31 @@
|
||||
.git
|
||||
.debug.conversations
|
||||
.debug
|
||||
.dev
|
||||
.playwright-mcp
|
||||
**/.playwright-mcp
|
||||
.paseo
|
||||
**/.paseo-provider-history
|
||||
.plans
|
||||
.tasks
|
||||
.valknut
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees
|
||||
.wrangler
|
||||
**/.wrangler
|
||||
**/.tanstack
|
||||
PLAN.md
|
||||
valknut-report.html
|
||||
valknut-report.json
|
||||
.env*
|
||||
**/.env*
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
*.pem
|
||||
**/*.pem
|
||||
**/.secrets
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
|
||||
@@ -76,7 +76,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -113,7 +112,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
@@ -170,7 +168,6 @@ jobs:
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -202,7 +199,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -227,7 +223,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
@@ -270,7 +265,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
@@ -27,7 +27,6 @@ jobs:
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
|
||||
@@ -5,7 +5,22 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- "scripts/**"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.base.json"
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
@@ -13,7 +28,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paseo_version:
|
||||
description: "Paseo version to build. Required when publish is true."
|
||||
description: "Expected source version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
@@ -24,15 +39,6 @@ on:
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
source_build:
|
||||
description: "Build from the checked-out source tree instead of npm."
|
||||
required: false
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
@@ -57,7 +63,6 @@ jobs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
source_build: ${{ steps.values.outputs.source_build }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
@@ -68,7 +73,6 @@ jobs:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -76,9 +80,9 @@ jobs:
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
||||
publish=false
|
||||
source_build=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
@@ -87,7 +91,6 @@ jobs:
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
source_build=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -104,24 +107,6 @@ jobs:
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||
true)
|
||||
source_build=true
|
||||
;;
|
||||
false)
|
||||
source_build=false
|
||||
;;
|
||||
auto)
|
||||
if [[ "${prerelease}" == "true" ]]; then
|
||||
source_build=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::source_build must be auto, true, or false."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
@@ -137,14 +122,13 @@ jobs:
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "source_build=${source_build}"
|
||||
echo "check_tag=${check_tag}"
|
||||
echo "publish_tags<<EOF"
|
||||
echo "${publish_tags}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
@@ -160,8 +144,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
@@ -193,8 +177,8 @@ jobs:
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
context: .
|
||||
file: docker/base/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
|
||||
@@ -84,6 +84,7 @@ valknut-report.json/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
packages/protocol/src/generated/validation/*.aot.ts
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.104-beta.4 - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
|
||||
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
|
||||
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
||||
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
||||
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
||||
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
|
||||
|
||||
### Improved
|
||||
|
||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
||||
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
||||
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
||||
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
|
||||
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -21,34 +21,36 @@ 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/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): model-provider catalog, generic config, OAuth store, CLI/app setup |
|
||||
| [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/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [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/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| 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/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [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/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [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/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -91,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- New fields: `.optional()` with a sensible default.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
|
||||
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
|
||||
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
|
||||
+30
-6
@@ -1,9 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM ${NODE_IMAGE}
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION=latest
|
||||
ARG PASEO_VERSION
|
||||
|
||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
WORKDIR /tmp/paseo-src
|
||||
COPY . .
|
||||
|
||||
RUN set -eux; \
|
||||
if [ -n "${PASEO_VERSION:-}" ]; then \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
fi; \
|
||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||
npm ci
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
@@ -29,13 +52,14 @@ RUN set -eux; \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
procps \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional \
|
||||
"@getpaseo/server@${PASEO_VERSION}" \
|
||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||
npm install -g /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
@@ -66,7 +90,7 @@ RUN set -eux; \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY rootfs/ /
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION
|
||||
|
||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
WORKDIR /tmp/paseo-src
|
||||
COPY . .
|
||||
|
||||
RUN set -eux; \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||
npm ci
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
PASEO_LISTEN=0.0.0.0:6767 \
|
||||
PASEO_WEB_UI_ENABLED=true \
|
||||
PASEO_LOG_FORMAT=json \
|
||||
PASEO_LOG_LEVEL=info \
|
||||
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||
CODEX_HOME=/home/paseo/.codex \
|
||||
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||
node --check "$server_entry"
|
||||
|
||||
RUN set -eux; \
|
||||
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||
groupmod --new-name paseo "$existing_group"; \
|
||||
elif [ -z "$existing_group" ]; then \
|
||||
groupadd --gid 1000 paseo; \
|
||||
fi; \
|
||||
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||
elif [ -z "$existing_user" ]; then \
|
||||
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||
fi; \
|
||||
mkdir -p \
|
||||
/workspace \
|
||||
"$PASEO_HOME" \
|
||||
"$CLAUDE_CONFIG_DIR" \
|
||||
"$CODEX_HOME" \
|
||||
"$XDG_CONFIG_HOME" \
|
||||
"$XDG_DATA_HOME" \
|
||||
"$XDG_STATE_HOME" \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 6767
|
||||
VOLUME ["/home/paseo"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||
@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Browser Capture Harness
|
||||
|
||||
The desktop capture harness is the real-Electron verification path for browser screenshots.
|
||||
It validates the compositor behavior that unit tests cannot see:
|
||||
|
||||
- the resident automation `<webview>` starts in the production parking state;
|
||||
- the parked guest remains paintable and has a copyable viewport frame;
|
||||
- the resident webview guest is sized to 1280x800 logical pixels;
|
||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
||||
stacking changes;
|
||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
||||
by retrying until the frame appears;
|
||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||
the permanent production parking state;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination.
|
||||
|
||||
Run it with the repo Electron:
|
||||
|
||||
```bash
|
||||
npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Run the browser automation fixture with:
|
||||
|
||||
```bash
|
||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
The automation group uses a real guest webview to verify the page-side ref contract:
|
||||
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
||||
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
||||
page-context evaluation, including passing a resolved ref element as the function argument.
|
||||
|
||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
||||
focus; a normal Electron app launch can still activate the app and steal focus.
|
||||
Harness windows are then created hidden, positioned in a screen corner, skipped from
|
||||
the taskbar where Electron supports it, and revealed with `showInactive()` from
|
||||
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
|
||||
the compositor only needs visible inactive windows, and harness runs must not steal
|
||||
focus from the person using the machine.
|
||||
|
||||
The harness writes PNG evidence and `results.json` to:
|
||||
|
||||
```text
|
||||
packages/desktop/capture-harness/out/
|
||||
```
|
||||
|
||||
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
||||
is usually saved as 2560x1600.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
||||
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
||||
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
||||
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
||||
overlap at `left:0`, `top:0`.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
invalidates before each attempt, and retries known first-frame failures within the
|
||||
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
||||
|
||||
---
|
||||
|
||||
## Runtime PID Lock
|
||||
|
||||
**Path:** `$PASEO_HOME/paseo.pid`
|
||||
|
||||
The daemon writes this file when it starts and removes it when it exits cleanly.
|
||||
It is a runtime lock, not a durable settings file.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `pid` | `number` | Owner process PID for stop/status operations |
|
||||
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
|
||||
| `hostname` | `string` | Host that created the lock |
|
||||
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
|
||||
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
|
||||
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
|
||||
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
|
||||
|
||||
Desktop-managed is a read-time classification by the desktop app: the daemon
|
||||
executable must be the desktop app executable or live inside the same desktop
|
||||
install. The CLI reports the raw `executablePath` but does not try to classify
|
||||
it because an npm-installed CLI has no authoritative desktop install root.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
|
||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||
|
||||
@@ -178,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
|
||||
+9
-10
@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
||||
|
||||
The official image:
|
||||
|
||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||
or from source-built workspace tarballs for beta images
|
||||
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
||||
- runs the daemon as the non-root `paseo` user
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
@@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
docker build -t paseo:local docker/base
|
||||
docker build -f docker/base/Dockerfile -t paseo:local .
|
||||
```
|
||||
|
||||
To bake a specific published npm version:
|
||||
To assert the source tree version while building:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=0.1.102 \
|
||||
-t paseo:0.1.102 \
|
||||
docker/base
|
||||
-f docker/base/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
The Docker workflow builds the image on pull requests and on `main` as a
|
||||
@@ -216,13 +216,12 @@ pushing a `v*` release tag:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
||||
from the checked-out source tree and publishes only the exact prerelease image
|
||||
tag for prerelease versions.
|
||||
|
||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
|
||||
+4
-9
@@ -6,7 +6,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — A runnable identity the user picks to start work: today that is an **Agent provider** (Claude Code, Codex, Paseo Agent); direction: also a named **Agent definition** (`reviewer`) that pins provider, model, prompt, and tools. One CLI namespace: `paseo run --agent <id>` resolves agent provider ids first, then agent definition ids. The running instance is an **Agent session** — "New Agent" starts a new agent session. Forbidden: "Task", "Job", "Run".
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
@@ -17,13 +17,11 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one agent provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Canonical term for the running instance (bare "Agent" is the identity being run, see **Agent**). Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Agent definition** — A Paseo-owned markdown persona in `$PASEO_HOME/agents/*.md`: frontmatter `name`, `prompt`, `model`, `tools`, `permissions`; body is the prompt. Selected via `agents.paseo.defaultAgent` (`defaultProfile` is a legacy alias). Direction: definitions join the `--agent` namespace (`paseo run --agent reviewer`), so a definition id must not shadow an agent provider id. Code: `loadAgentDefinition` (`packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts:62`), config keys (`packages/server/src/server/agent/providers/paseo-agent/config.ts:174`). Forbidden: "Profile", "Prompt profile" as synonyms.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). 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.
|
||||
- **Agent provider** — The harness/backend that runs agents (Claude Code, Codex, Copilot, OpenCode, Pi, OMP, Paseo Agent with id `paseo`). UI: "Agent provider" (target label; surfaces still saying bare "Provider" migrate as touched). Wire and config keep `provider` — labels move, the protocol does not. Code: `AgentProvider` (`packages/protocol/src/agent-types.ts:3`), `ProviderSnapshotEntrySchema` (`packages/protocol/src/messages.ts:262`). Don't confuse with: **Model provider**. Forbidden: bare "Provider" as a new UI label — always qualify which layer.
|
||||
- **Model provider** — A model API attached to the Paseo Agent provider: a type (`openrouter`, `openai`, `anthropic`, `openai-codex`, …) plus key/OAuth and models, keyed by instance name (`openrouter-main`, `chatgpt`). UI: "Model providers" (Paseo Agent settings). CLI: `paseo provider add` — the CLI `provider` noun means model provider. Config: `agents.paseo.providers` (`packages/server/src/server/agent/providers/paseo-agent/config.ts:169`); wire: `PaseoAgentProviderTypeSchema` (`packages/protocol/src/messages.ts:1924`). Don't confuse with: **Agent provider**. Forbidden: "Inference provider" (old docs term), bare "Provider" as a new UI label.
|
||||
- **Model** — A specific LLM offered by a provider; for the Paseo Agent provider, addressed as `<modelProviderName>/<modelId>`. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
@@ -41,8 +39,5 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- Bare "Provider" survives as a UI label in the provider picker and settings; target is "Agent provider" / "Model providers" per the entries above. Migrate labels as surfaces are touched — no big-bang rename.
|
||||
- CLI `paseo provider` describes itself as "Manage agent providers" (`packages/cli/src/commands/provider/index.ts:11`) but manages **model providers**. Fix the description; keep the noun.
|
||||
- CLI `paseo run --provider <id>` selects an agent provider. Direction: add `--agent <id>` as the canonical flag resolving one namespace (agent provider ids, then agent definition ids), keep `--provider` as a COMPAT alias.
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
# Paseo Agent provider
|
||||
|
||||
Paseo Agent is the built-in agent provider that runs Pi's coding-agent harness **in
|
||||
process** (no `pi` CLI, no `~/.pi` discovery). Its provider id is **`paseo`** and its
|
||||
model backends are configured under `agents.paseo` in `$PASEO_HOME/config.json`.
|
||||
|
||||
Use it like any other agent provider:
|
||||
|
||||
```bash
|
||||
paseo run --provider paseo --model <providerInstance>/<modelId> "Reply with pong"
|
||||
```
|
||||
|
||||
Example: `--model openrouter/openai/gpt-4o-mini` selects the `openrouter` provider
|
||||
instance and the `openai/gpt-4o-mini` model exposed by that instance.
|
||||
|
||||
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
|
||||
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before
|
||||
> smoking, otherwise stale `dist` may reject or omit `agents.paseo` behavior. Always pass
|
||||
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
|
||||
> daemon on `:6767`.
|
||||
|
||||
## Timeline and persistence
|
||||
|
||||
Paseo Agent does not write or resume Pi CLI session files such as
|
||||
`~/.pi/agent/sessions/*`; it runs Pi's harness in process and currently reports no
|
||||
provider-owned session persistence. App refreshes and daemon-side agent reloads therefore
|
||||
depend on Paseo's own timeline rows, not on a Pi history replay. Submitted prompts must be
|
||||
emitted as `user_message` timeline rows when the turn starts; assistant text, reasoning,
|
||||
and tool calls arrive later from Pi events and are mapped into the same Paseo timeline.
|
||||
|
||||
## Provider catalog
|
||||
|
||||
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
|
||||
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
|
||||
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
|
||||
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
|
||||
thinking maps, and OAuth registry data at runtime.
|
||||
|
||||
The current catalog contains four entries:
|
||||
|
||||
| id | Pi provider | default models | auth source |
|
||||
| ------------- | -------------- | -------------- | --------------------------------------------- |
|
||||
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
|
||||
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
|
||||
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
|
||||
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
|
||||
|
||||
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large.
|
||||
Users can store the OpenRouter credential first, but they must choose explicit model ids
|
||||
before running Paseo Agent through that provider. The other catalog entries expose Pi's
|
||||
bundled model list unless an instance sets `options.models`.
|
||||
|
||||
## Config shape
|
||||
|
||||
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
|
||||
keys; provider types are catalog ids. Use the catalog id as the default instance name,
|
||||
or create several instances of the same type with different models, keys, or endpoint
|
||||
overrides.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"paseo": {
|
||||
"defaultAgent": "orchestrator",
|
||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
||||
"providers": {
|
||||
"openrouter-main": {
|
||||
"type": "openrouter",
|
||||
"options": {
|
||||
"apiKey": "$OPENROUTER_API_KEY",
|
||||
"models": [
|
||||
{ "id": "openai/gpt-4o-mini", "label": "GPT-4o mini" },
|
||||
{ "id": "anthropic/claude-3.7-sonnet", "reasoning": true },
|
||||
],
|
||||
},
|
||||
},
|
||||
"chatgpt": {
|
||||
"type": "chatgpt",
|
||||
},
|
||||
"kimi": {
|
||||
"type": "kimi",
|
||||
"options": {
|
||||
"apiKey": "$KIMI_API_KEY",
|
||||
"models": [{ "id": "k2p7" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Most options are overrides over Pi-derived provider data:
|
||||
|
||||
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
|
||||
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
|
||||
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
|
||||
mirrors Pi's config-value semantics: literals and commands count as configured; env
|
||||
references count only when every referenced env var is set in the daemon environment.
|
||||
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
|
||||
config.
|
||||
- `models[]` is an instance override. Omit it to use that entry's default policy, which
|
||||
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
|
||||
when a single backend serves mixed protocols or when Pi has no data for a custom id.
|
||||
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
|
||||
|
||||
Env references make config portable: `config.json` can be copied between machines while
|
||||
the secret stays in that machine's daemon environment or keychain command.
|
||||
|
||||
`defaultModel` is optional and uses the same `<providerInstance>/<modelId>` form. An
|
||||
explicit session model wins, then the selected agent definition's model, then
|
||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
||||
|
||||
`defaultProfile` is still accepted as a legacy alias for `defaultAgent`.
|
||||
|
||||
## Authentication
|
||||
|
||||
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
|
||||
Redacted provider responses include an optional `auth` state:
|
||||
|
||||
- `Connected` means the key or credential expression resolves locally. It does not make a
|
||||
network call, so a fake literal key still reports connected until a real session uses it.
|
||||
- `Needs attention` means the instance exists but the auth expression does not currently
|
||||
resolve, the OAuth store binding does not match, or another auth precondition is missing.
|
||||
- `not configured` is used by older/no-auth responses.
|
||||
|
||||
The redacted provider `available` flag mirrors local credential availability. The Paseo
|
||||
Agent runtime still needs at least one exposed model before it can start a session.
|
||||
|
||||
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
|
||||
output.
|
||||
|
||||
### OAuth store
|
||||
|
||||
OAuth credentials live in Paseo's store, not in another tool's auth file:
|
||||
|
||||
```text
|
||||
$PASEO_HOME/paseo-agent/auth.json
|
||||
```
|
||||
|
||||
The store is created through Pi's `AuthStorage`. The parent directory is private and the
|
||||
file is written mode `0600`; Pi also re-chmods on write. During a Paseo Agent session,
|
||||
Pi reads the credential, refreshes expired access tokens, and persists refresh-token
|
||||
rotation back into the same Paseo-owned file.
|
||||
|
||||
Stored OAuth credentials are bound to the provider instance's `{ flow, baseUrl }`. If the
|
||||
catalog flow or configured base URL changes, the old credential is left on disk but the
|
||||
provider reports `Needs attention` until it is authorized again. This prevents a token
|
||||
for one OAuth target from silently being used against another.
|
||||
|
||||
The OAuth implementation uses Pi's OAuth registry. Catalog OAuth entries are limited to
|
||||
flows that registry knows about: `openai-codex`, `anthropic`, and `github-copilot`.
|
||||
The current catalog only uses `openai-codex` for `chatgpt`.
|
||||
|
||||
## CLI setup
|
||||
|
||||
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
|
||||
isolated daemon.
|
||||
|
||||
Commands:
|
||||
|
||||
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
|
||||
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
|
||||
`--device-code`, `--json`, and `--host` as needed.
|
||||
- `paseo provider ls` lists configured instances and redacted auth state.
|
||||
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
|
||||
it pointed at that instance.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$OPENROUTER_API_KEY" |
|
||||
paseo provider add \
|
||||
openrouter \
|
||||
--api-key-stdin \
|
||||
--model openai/gpt-4o-mini \
|
||||
--host 127.0.0.1:7911
|
||||
```
|
||||
|
||||
```bash
|
||||
paseo provider add kimi \
|
||||
--model k2p7 \
|
||||
--host 127.0.0.1:7911
|
||||
# Press Enter at the API-key prompt to store "$KIMI_API_KEY".
|
||||
```
|
||||
|
||||
```bash
|
||||
paseo provider add chatgpt --device-code --host 127.0.0.1:7911
|
||||
```
|
||||
|
||||
Without an `[id]`, `provider add` prints the catalog and prompts for a selection. For
|
||||
API-key providers it writes the configured key expression into `config.json`. For OAuth
|
||||
providers it first writes the provider config, then stores the OAuth credential in the
|
||||
daemon's Paseo-owned auth store.
|
||||
|
||||
`provider add` is idempotent for the same instance name: running it again updates the
|
||||
existing entry instead of creating a duplicate. `provider rm <name>` removes only that
|
||||
provider instance and clears `defaultModel` if it pointed at the removed instance.
|
||||
|
||||
## App setup
|
||||
|
||||
The app uses the same catalog and config RPCs as the CLI. In Settings, open the host
|
||||
settings, then the Paseo Agent provider section. The app fetches the catalog from the
|
||||
connected daemon, shows catalog entries in the picker, and renders either an API-key
|
||||
form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two
|
||||
daemon-run starts: browser redirect for the local fast path and device code for
|
||||
remote-safe authorization. Relay connections present device code first; direct TCP does
|
||||
not prove same-machine by itself, so both options remain available.
|
||||
|
||||
Configured rows show the redacted provider state from the daemon: label, provider type,
|
||||
models, availability, and auth state. The app gates this UI on
|
||||
`server_info.features.paseoAgentCatalog`; older daemons show an update-host affordance
|
||||
instead of trying to synthesize the feature through older RPCs.
|
||||
|
||||
## Wire surface
|
||||
|
||||
The catalog surface is gated by:
|
||||
|
||||
```text
|
||||
server_info.features.paseoAgentCatalog
|
||||
```
|
||||
|
||||
RPC names use dotted namespaces:
|
||||
|
||||
| request | response |
|
||||
| --------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `config.paseo_agent.get_catalog.request` | `config.paseo_agent.get_catalog.response` |
|
||||
| `config.paseo_agent.get_providers.request` | `config.paseo_agent.get_providers.response` |
|
||||
| `config.paseo_agent.set_provider.request` | `config.paseo_agent.set_provider.response` |
|
||||
| `config.paseo_agent.remove_provider.request` | `config.paseo_agent.remove_provider.response` |
|
||||
| `config.paseo_agent.oauth.start.request` | `config.paseo_agent.oauth.start.response` |
|
||||
| `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` |
|
||||
| `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` |
|
||||
|
||||
`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and
|
||||
`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can
|
||||
complete the localhost redirect on the daemon host. Browser mode returns
|
||||
`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback
|
||||
listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve,
|
||||
stores the credential, and returns the redacted `auth` state. Device-code mode returns
|
||||
`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?,
|
||||
expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the
|
||||
provider authorizes.
|
||||
|
||||
Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names,
|
||||
OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse
|
||||
new catalog entries, and new clients can show "update host" only when the daemon lacks the
|
||||
catalog feature.
|
||||
|
||||
## Adding a catalog entry
|
||||
|
||||
Adding a new model-provider type should be a data change:
|
||||
|
||||
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
|
||||
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
|
||||
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
|
||||
should not expose its full model list by default.
|
||||
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
|
||||
resolution simple.
|
||||
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
|
||||
and CLI/app rendering if the new entry exercises a new shape.
|
||||
|
||||
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
|
||||
what unlocks CLI setup, app setup, redacted provider state, config persistence, and model
|
||||
addressing.
|
||||
|
||||
## MCP tools
|
||||
|
||||
Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the
|
||||
daemon-injected `paseo` MCP server (and any other configured MCP server) is available to
|
||||
the model. On session start the provider connects to each server, lists its tools, and
|
||||
registers them as Pi tools named `<serverName>__<toolName>`; tool input schemas (JSON
|
||||
Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map
|
||||
back to the model. Connections are torn down on session close. Servers that fail to
|
||||
connect or list are logged and skipped rather than failing the session.
|
||||
|
||||
Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP);
|
||||
SSE and stdio transports are also wired via the MCP SDK. No extra config is needed: MCP
|
||||
servers come from Paseo's normal injection/config, not from `agents.paseo`.
|
||||
|
||||
The internal `paseo` MCP server is required for Paseo Agent sessions and is injected even
|
||||
when the global `mcp.injectIntoAgents` setting is disabled for other providers.
|
||||
|
||||
## Agent definitions
|
||||
|
||||
Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`.
|
||||
Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves
|
||||
to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable
|
||||
agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"paseo": {
|
||||
"defaultAgent": "orchestrator",
|
||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
||||
"providers": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Example `$PASEO_HOME/agents/orchestrator.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Orchestrator
|
||||
description: Coordinates work through Paseo-managed agents
|
||||
prompt: extend
|
||||
mcp: [paseo]
|
||||
model: openrouter-main/openai/gpt-4o-mini
|
||||
tools: [read, grep, paseo__list_agents, paseo__create_agent]
|
||||
permissions:
|
||||
- tool: paseo__archive_*
|
||||
action: deny
|
||||
---
|
||||
|
||||
!{{./partials/collaboration.md}}
|
||||
|
||||
Use the Paseo MCP tools to inspect active agents, create focused helper agents, and
|
||||
summarize handoffs clearly.
|
||||
|
||||
!{{./partials/review-rules.md}}
|
||||
```
|
||||
|
||||
`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to
|
||||
the append list. `prompt: override` uses the agent body as the custom base prompt, so
|
||||
Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is
|
||||
appended after the agent, and the daemon-level append prompt is appended last.
|
||||
|
||||
Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`,
|
||||
`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit
|
||||
project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md`
|
||||
discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default:
|
||||
an explicit session model wins, then the selected agent model, then
|
||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
||||
|
||||
Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`.
|
||||
Paths are relative to the file containing the directive and are confined to
|
||||
`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial
|
||||
chains, oversized definitions, and frontmatter inside partials are rejected.
|
||||
|
||||
`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can
|
||||
call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is
|
||||
an ordered first-match policy for active tool calls. The first matching `tool` pattern
|
||||
wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's
|
||||
tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools.
|
||||
|
||||
`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon
|
||||
MCP injection still supplies the actual server; if an agent declares an MCP server that
|
||||
is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues.
|
||||
+1
-1
@@ -73,7 +73,7 @@ Anyone who builds software:
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, 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)
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Protocol Validation
|
||||
|
||||
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
|
||||
|
||||
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
|
||||
|
||||
## Runtime Path
|
||||
|
||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
||||
|
||||
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
||||
|
||||
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
|
||||
|
||||
## Codegen Ownership
|
||||
|
||||
The protocol package owns generation.
|
||||
|
||||
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
||||
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
||||
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
||||
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
||||
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
||||
|
||||
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
||||
|
||||
## Regression Tests
|
||||
|
||||
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
|
||||
|
||||
- discriminated-union branch output must propagate `.default()` fields
|
||||
- current sequential item routing must accept `tool_call`-like status branches
|
||||
- generated runtime imports must keep `.js` extensions for packaged Node ESM
|
||||
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
|
||||
|
||||
## Schema Purity
|
||||
|
||||
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
|
||||
|
||||
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
|
||||
|
||||
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
|
||||
@@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
+2
-3
@@ -50,7 +50,7 @@ npm run release:patch
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -281,8 +281,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
-f publish=true
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
|
||||
@@ -45,7 +45,6 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit (paseo) npmDeps;
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
|
||||
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||
|
||||
@@ -52,7 +52,6 @@ buildNpmPackage rec {
|
||||
# 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;
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# 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).
|
||||
|
||||
Generated
+80
-2905
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -58,7 +58,7 @@
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
|
||||
type PaseoAgentDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "close"
|
||||
| "connect"
|
||||
| "removePaseoAgentProvider"
|
||||
| "setPaseoAgentProvider"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
>;
|
||||
|
||||
interface ApiKeyProviderInput {
|
||||
catalogId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface OAuthProviderInput {
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
interface ExpectedProvider {
|
||||
name: string;
|
||||
providerLabel: string;
|
||||
auth: "Connected" | "Needs attention";
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
async function connectPaseoAgentClient(): Promise<PaseoAgentDaemonClient> {
|
||||
return connectDaemonClient<PaseoAgentDaemonClient>({ clientIdPrefix: "paseo-agent-e2e" });
|
||||
}
|
||||
|
||||
export async function openPaseoAgentSettings(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click();
|
||||
const sheet = page.getByTestId("paseo-agent-settings-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill(provider.apiKey);
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function startOAuthProviderSignIn(
|
||||
page: Page,
|
||||
provider: OAuthProviderInput,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Sign in with browser", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectModelProviderListed(
|
||||
page: Page,
|
||||
expected: ExpectedProvider,
|
||||
): Promise<void> {
|
||||
const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`;
|
||||
await expect(
|
||||
page.getByRole("listitem", {
|
||||
name: new RegExp(
|
||||
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
|
||||
),
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function seedChatGptProvider(providerName: string): Promise<void> {
|
||||
const client = await connectPaseoAgentClient();
|
||||
try {
|
||||
await client.setPaseoAgentProvider({
|
||||
name: providerName,
|
||||
providerType: "chatgpt",
|
||||
options: {
|
||||
models: [{ id: "gpt-5.4-mini", reasoning: true }],
|
||||
},
|
||||
});
|
||||
await client.storePaseoAgentOAuthCredential({
|
||||
name: providerName,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "fake-access-token",
|
||||
refresh: "fake-refresh-token",
|
||||
expires: 4_102_444_800,
|
||||
futureField: { passthrough: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupPaseoAgentProviders(providerNames: Iterable<string>): Promise<void> {
|
||||
const client = await connectPaseoAgentClient();
|
||||
try {
|
||||
for (const name of providerNames) {
|
||||
await client.removePaseoAgentProvider(name);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id";
|
||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||
// button (universal) and each git project's own new-worktree icon (preselects
|
||||
// that project). These specs prove the global entry opens the screen, the
|
||||
// project icon preselects the right project across the reused 'new' screen, and
|
||||
// non-git projects never offer the worktree Isolation control.
|
||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||
// the global button (universal) and each project's per-row New workspace icon
|
||||
// (preselects that project) — shown for git projects and for non-git projects on
|
||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||
// screen, the project icon preselects the right project across the reused 'new'
|
||||
// screen, and non-git projects never offer the worktree Isolation control.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
@@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (its row has no new-worktree icon).
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
addApiKeyProvider,
|
||||
cleanupPaseoAgentProviders,
|
||||
expectModelProviderListed,
|
||||
openPaseoAgentSettings,
|
||||
seedChatGptProvider,
|
||||
startOAuthProviderSignIn,
|
||||
} from "./helpers/paseo-agent";
|
||||
|
||||
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
|
||||
const CLOSE_PROVIDER = "phase-e-close-ui";
|
||||
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
|
||||
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
|
||||
const RENAME_PROVIDER = "phase-e-rename-ui";
|
||||
|
||||
test.describe("Paseo Agent provider configuration", () => {
|
||||
const providerNamesToCleanup = new Set<string>();
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPaseoAgentProviders(providerNamesToCleanup);
|
||||
providerNamesToCleanup.clear();
|
||||
});
|
||||
|
||||
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await addApiKeyProvider(page, {
|
||||
catalogId: "openrouter",
|
||||
apiKey: "sk-or-phase-e-write-only",
|
||||
});
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill("sk-or-default-name");
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await startOAuthProviderSignIn(page, {
|
||||
catalogId: "chatgpt",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows user-facing provider picker rows", async ({ page }) => {
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
|
||||
const picker = page.getByTestId("paseo-agent-provider-picker");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("paseo-agent-catalog-entry-openrouter")
|
||||
.getByText("API key", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
|
||||
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
|
||||
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CHATGPT_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: CHATGPT_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("renames a configured provider row without changing its stored credential key", async ({
|
||||
page,
|
||||
}) => {
|
||||
providerNamesToCleanup.add(RENAME_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(RENAME_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
|
||||
await page.getByLabel("Provider name").fill("Work account");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: "Work account",
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CLOSE_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CLOSE_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await expectModelProviderListed(page, {
|
||||
name: CLOSE_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
|
||||
await page.getByLabel("Close", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText("ten-second-stream");
|
||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
|
||||
await row.click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
buildFakeScheduleHostWorkspace,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
@@ -82,9 +81,9 @@ test.describe("Schedules project target", () => {
|
||||
await page.getByRole("button", { name: "Schedules" }).click();
|
||||
await expect(page).toHaveURL(/\/schedules$/);
|
||||
await expect(page).not.toHaveURL(/\/h\//);
|
||||
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "New schedule" }).click();
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
|
||||
@@ -127,10 +126,8 @@ test.describe("Schedules project target", () => {
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "New schedule" }).click();
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
|
||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
||||
test.describe("Model B sidebar shape", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
|
||||
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
||||
@@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The per-project "+ New workspace" row is gone. The git project keeps a
|
||||
// per-row new-worktree icon (revealed on hover); the non-git project has
|
||||
// none, since worktree creation needs a git checkout.
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The global new-workspace button is the universal entry — present for both
|
||||
// kinds regardless of their per-row affordance.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -36,13 +36,6 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
|
||||
return "Review";
|
||||
}
|
||||
|
||||
const BROWSER_INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
||||
fix: "workspace.browser.annotate.intents.fix",
|
||||
change: "workspace.browser.annotate.intents.change",
|
||||
question: "workspace.browser.annotate.intents.question",
|
||||
approve: "workspace.browser.annotate.intents.approve",
|
||||
};
|
||||
|
||||
function getTextAttachmentSubtitle(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
@@ -96,11 +89,10 @@ export function getWorkspaceAttachmentPillContent(
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
const intent = attachment.attachment.intent;
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
||||
subtitle: t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
|
||||
@@ -21,12 +21,6 @@ export interface AttachmentMetadata {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind of review feedback the user is attaching to a selected browser
|
||||
* element, sent to the agent alongside the element context.
|
||||
*/
|
||||
export type BrowserAnnotationIntent = "fix" | "change" | "question" | "approve";
|
||||
|
||||
export interface BrowserElementAttachment {
|
||||
url: string;
|
||||
selector: string;
|
||||
@@ -50,8 +44,6 @@ export interface BrowserElementAttachment {
|
||||
children: string[];
|
||||
/** Free-text review note the user wrote about this element, if any. */
|
||||
comment?: string;
|
||||
/** What the user wants the agent to do with this element, if annotated. */
|
||||
intent?: BrowserAnnotationIntent;
|
||||
/**
|
||||
* Cropped screenshot of the selected element, sent to the agent as an image
|
||||
* alongside the textual element context. Persisted via the attachment store;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
@@ -8,20 +9,6 @@ import {
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
removeItem: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
@@ -30,9 +17,28 @@ type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
||||
|
||||
class FakeStateStorage implements StateStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
public getItem = (key: string): string | null => this.values.get(key) ?? null;
|
||||
|
||||
public setItem = (key: string, value: string): void => {
|
||||
this.values.set(key, value);
|
||||
};
|
||||
|
||||
public removeItem = (key: string): void => {
|
||||
this.values.delete(key);
|
||||
};
|
||||
|
||||
public clear(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonClient {
|
||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||
|
||||
public on(
|
||||
@@ -55,6 +61,107 @@ class FakeDaemonClient {
|
||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||
this.handler?.(nextRequest);
|
||||
}
|
||||
|
||||
public payloadAt(index: number): BrowserAutomationResponsePayload {
|
||||
const response = this.sentResponses[index];
|
||||
if (!response) {
|
||||
throw new Error(`Missing browser automation response at index ${index}`);
|
||||
}
|
||||
return response.payload;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBrowserBridge {
|
||||
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
|
||||
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
|
||||
[];
|
||||
public readonly unregisteredWorkspaceBrowsers: string[] = [];
|
||||
public readonly clearedPartitions: string[] = [];
|
||||
public readonly activeWorkspaceBrowsers: Array<{
|
||||
browserId: string | null;
|
||||
workspaceId: string;
|
||||
}> = [];
|
||||
public response: BrowserAutomationResponsePayload | null = null;
|
||||
public thrownError: unknown = null;
|
||||
|
||||
public executeAutomationCommand = async (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
): Promise<BrowserAutomationResponsePayload> => {
|
||||
this.executedRequests.push(request);
|
||||
if (this.thrownError) {
|
||||
throw this.thrownError;
|
||||
}
|
||||
return this.response ?? currentListTabsPayload(request.requestId);
|
||||
};
|
||||
|
||||
public registerWorkspaceBrowser = async (input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> => {
|
||||
this.registeredWorkspaceBrowsers.push(input);
|
||||
};
|
||||
|
||||
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
|
||||
this.unregisteredWorkspaceBrowsers.push(browserId);
|
||||
};
|
||||
|
||||
public clearPartition = async (browserId: string): Promise<void> => {
|
||||
this.clearedPartitions.push(browserId);
|
||||
};
|
||||
|
||||
public setWorkspaceActiveBrowser = async (input: {
|
||||
browserId: string | null;
|
||||
workspaceId: string;
|
||||
}): Promise<void> => {
|
||||
this.activeWorkspaceBrowsers.push(input);
|
||||
};
|
||||
}
|
||||
|
||||
class FakeResidentBrowser {
|
||||
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
|
||||
|
||||
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
|
||||
this.ensuredWebviews.push(input);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
class BrowserAutomationHandlerHarness {
|
||||
public readonly client = new FakeDaemonClient();
|
||||
public readonly browser = new FakeBrowserBridge();
|
||||
public readonly resident = new FakeResidentBrowser();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
public mount(
|
||||
input: {
|
||||
serverId?: string;
|
||||
host?: DesktopHostBridge | null;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
} = {},
|
||||
): void {
|
||||
this.unsubscribe = mountBrowserAutomationHandler({
|
||||
client: this.client,
|
||||
...(input.serverId ? { serverId: input.serverId } : {}),
|
||||
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
|
||||
ensureResidentBrowserWebview: this.resident.ensure,
|
||||
...(input.registrationWaitTimeoutMs !== undefined
|
||||
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
|
||||
: {}),
|
||||
...(input.registrationPollIntervalMs !== undefined
|
||||
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
public unmount(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
public receive(request: BrowserAutomationExecuteRequest): void {
|
||||
this.client.receive(request);
|
||||
}
|
||||
}
|
||||
|
||||
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||
@@ -70,45 +177,69 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "/repo",
|
||||
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: {
|
||||
command: "new_tab",
|
||||
args: { url: "https://example.com" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
function browserResizeRequest(
|
||||
browserId: string,
|
||||
input: { workspaceId?: string } = {},
|
||||
): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-resize",
|
||||
agentId: "agent-1",
|
||||
workspaceId: input.workspaceId ?? "wks_workspace_a",
|
||||
command: {
|
||||
command: "resize",
|
||||
args: { browserId, width: 1024, height: 768 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-close-tab",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: {
|
||||
command: "close_tab",
|
||||
args: { browserId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||
return {
|
||||
requestId,
|
||||
ok: true as const,
|
||||
ok: true,
|
||||
result: {
|
||||
command: "list_tabs" as const,
|
||||
command: "list_tabs",
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||
return {
|
||||
requestId,
|
||||
ok: true as const,
|
||||
ok: true,
|
||||
result: {
|
||||
command: "list_tabs" as const,
|
||||
command: "list_tabs",
|
||||
tabs: currentBrowserTabs(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function waitForAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
function currentBrowserTabs() {
|
||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||
browserId: browser.browserId,
|
||||
workspaceId: "/repo",
|
||||
workspaceId: "wks_workspace_a",
|
||||
url: browser.url,
|
||||
title: browser.title,
|
||||
isActive: true,
|
||||
@@ -116,151 +247,268 @@ function currentBrowserTabs() {
|
||||
}));
|
||||
}
|
||||
|
||||
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
|
||||
expect(payload).toMatchObject({
|
||||
requestId: "req-new",
|
||||
ok: true,
|
||||
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
|
||||
});
|
||||
if (!payload.ok || payload.result.command !== "new_tab") {
|
||||
throw new Error("Expected browser_new_tab success payload");
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
|
||||
return useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function waitForRegistrationTimeout(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
const browserAutomationStorage = new FakeStateStorage();
|
||||
useBrowserStore.persist.setOptions({
|
||||
storage: createJSONStorage(() => browserAutomationStorage),
|
||||
});
|
||||
useWorkspaceLayoutStore.persist.setOptions({
|
||||
storage: createJSONStorage(() => browserAutomationStorage),
|
||||
});
|
||||
|
||||
describe("mountBrowserAutomationHandler", () => {
|
||||
beforeEach(() => {
|
||||
browserAutomationStorage.clear();
|
||||
useBrowserStore.setState({ browsersById: {} });
|
||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||
});
|
||||
|
||||
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
|
||||
const setAgentActiveBrowser = vi.fn(async () => undefined);
|
||||
const registerWorkspaceBrowser = vi.fn(async () => undefined);
|
||||
const ensureResidentBrowserWebview = vi.fn();
|
||||
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
|
||||
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo",
|
||||
workspaceId: "wks_workspace_a",
|
||||
});
|
||||
if (!workspaceKey) throw new Error("expected workspace key");
|
||||
const focusedTabId = useWorkspaceLayoutStore
|
||||
if (!workspaceKey) {
|
||||
throw new Error("Expected workspace key");
|
||||
}
|
||||
const previousFocusedTabId = useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||
if (!focusedTabId) throw new Error("expected focused tab");
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () =>
|
||||
({
|
||||
browser: {
|
||||
executeAutomationCommand,
|
||||
registerWorkspaceBrowser,
|
||||
setWorkspaceActiveBrowser,
|
||||
setAgentActiveBrowser,
|
||||
},
|
||||
}) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
});
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
const payload = client.sentResponses[0]?.payload;
|
||||
expect(payload?.ok).toBe(true);
|
||||
if (!payload?.ok) throw new Error("expected success");
|
||||
expect(payload.result.command).toBe("new_tab");
|
||||
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
|
||||
expect(payload.result.url).toBe("https://example.com");
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toContainEqual(
|
||||
expect.objectContaining({ target: { kind: "browser", browserId: payload.result.browserId } }),
|
||||
);
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout?.root.kind).toBe("pane");
|
||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
||||
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
|
||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
||||
browserId: payload.result.browserId,
|
||||
workspaceId: "/repo",
|
||||
});
|
||||
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
browserId: payload.result.browserId,
|
||||
});
|
||||
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
||||
browserId: payload.result.browserId,
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
||||
expect(openedTabs).toEqual([
|
||||
expect.objectContaining({
|
||||
target: { kind: "browser", browserId: result.browserId },
|
||||
}),
|
||||
]);
|
||||
expect(layout?.root).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "pane",
|
||||
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
|
||||
}),
|
||||
);
|
||||
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
||||
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
||||
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||
]);
|
||||
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
{ browserId: result.browserId, url: "https://example.com" },
|
||||
]);
|
||||
expect(browser.browser.executedRequests).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new:list_tabs",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
command: { command: "list_tabs", args: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns browser_timeout when the resident webview does not register", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const ensureResidentBrowserWebview = vi.fn();
|
||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.response = emptyListTabsPayload();
|
||||
browser.mount({
|
||||
serverId: "server-1",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs: 1,
|
||||
registrationPollIntervalMs: 1,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await waitForAsyncWork();
|
||||
browser.receive(browserNewTabRequest());
|
||||
await waitForRegistrationTimeout();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_timeout",
|
||||
retryable: true,
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_timeout",
|
||||
message: expect.stringContaining("Timed out waiting for browser tab"),
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
||||
]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
);
|
||||
expect(client.sentResponses[0]?.payload).not.toMatchObject({
|
||||
ok: true,
|
||||
result: { command: "new_tab" },
|
||||
});
|
||||
]);
|
||||
});
|
||||
|
||||
test("wraps browser_new_tab registration bridge errors in a response", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => {
|
||||
throw new Error("IPC registration check failed");
|
||||
});
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
serverId: "server-1",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
});
|
||||
test("browser_new_tab wraps registration bridge errors in a response", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.thrownError = new Error("IPC registration check failed");
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-new",
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unknown_error",
|
||||
message: "IPC registration check failed",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("browser_resize updates resident webview dimensions", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserResizeRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(1)).toEqual({
|
||||
requestId: "req-resize",
|
||||
ok: true,
|
||||
result: {
|
||||
command: "resize",
|
||||
browserId: result.browserId,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
});
|
||||
expect(browser.browser.executedRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("browser_resize returns not found for a tab outside the request workspace", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(1)).toEqual({
|
||||
requestId: "req-resize",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unknown_error",
|
||||
message: "IPC registration check failed",
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${result.browserId}`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("sends a success response from the desktop bridge", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
ok: true as const,
|
||||
result: { command: "list_tabs" as const, tabs: [] },
|
||||
}));
|
||||
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
workspaceId: "wks_workspace_a",
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
throw new Error("Expected workspace key");
|
||||
}
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
||||
expect(client.sentResponses).toEqual([
|
||||
browser.receive(browserCloseTabRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(1)).toEqual({
|
||||
requestId: "req-close-tab",
|
||||
ok: true,
|
||||
result: { command: "close_tab", browserId: result.browserId },
|
||||
});
|
||||
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
|
||||
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
|
||||
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
|
||||
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
|
||||
expect(currentBrowserTabs()).toEqual([]);
|
||||
});
|
||||
|
||||
test("browser_close_tab returns not found after the tab is gone", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ serverId: "server-1" });
|
||||
|
||||
browser.receive(browserNewTabRequest());
|
||||
await flushAsyncWork();
|
||||
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||
|
||||
browser.receive(browserCloseTabRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
browser.receive(browserCloseTabRequest(result.browserId));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.payloadAt(2)).toEqual({
|
||||
requestId: "req-close-tab",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${result.browserId}`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("non-new-tab requests send the desktop bridge response", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.response = {
|
||||
requestId: "desktop-req",
|
||||
ok: true,
|
||||
result: { command: "list_tabs", tabs: [] },
|
||||
};
|
||||
browser.mount();
|
||||
const request = browserAutomationRequest();
|
||||
|
||||
browser.receive(request);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.browser.executedRequests).toEqual([request]);
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
@@ -272,14 +520,14 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing bridge sends browser_unsupported", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
||||
test("missing desktop bridge sends browser_unsupported", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount({ host: null });
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(client.sentResponses).toEqual([
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
@@ -287,7 +535,7 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
message: "Browser automation is not available in this app runtime.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
@@ -296,80 +544,68 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
});
|
||||
|
||||
test("typed bridge errors become failure responses", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({
|
||||
browser: {
|
||||
executeAutomationCommand: async () => {
|
||||
throw {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
};
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.thrownError = {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
};
|
||||
browser.mount();
|
||||
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_tab_not_found",
|
||||
message: "Browser tab browser-1 was not found.",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
|
||||
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({
|
||||
browser: {
|
||||
executeAutomationCommand: async () => {
|
||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.browser.thrownError = new Error(
|
||||
'No handler registered for "paseo:browser:execute-automation-command"',
|
||||
);
|
||||
browser.mount();
|
||||
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(browser.client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Browser automation is not implemented by this app build yet.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
|
||||
test("unsubscribe stops handling requests", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const executeAutomationCommand = vi.fn(async () => ({
|
||||
requestId: "req-1",
|
||||
ok: true as const,
|
||||
result: { command: "list_tabs" as const, tabs: [] },
|
||||
}));
|
||||
const unsubscribe = mountBrowserAutomationHandler({
|
||||
client,
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
||||
});
|
||||
test("unsubscribe stops handling browser automation requests", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
browser.mount();
|
||||
|
||||
unsubscribe();
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
browser.unmount();
|
||||
browser.receive(browserAutomationRequest());
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
||||
expect(client.sentResponses).toEqual([]);
|
||||
expect(browser.browser.executedRequests).toEqual([]);
|
||||
expect(browser.client.sentResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
||||
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
||||
import {
|
||||
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
|
||||
removeResidentBrowserWebview,
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
@@ -40,7 +45,7 @@ export function mountBrowserAutomationHandler(
|
||||
options: BrowserAutomationHandlerOptions,
|
||||
): () => void {
|
||||
const getHost = options.getHost ?? getDesktopHost;
|
||||
return options.client.on("browser.automation.execute.request", (request) => {
|
||||
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
|
||||
void handleBrowserAutomationRequest({
|
||||
client: options.client,
|
||||
getHost,
|
||||
@@ -56,6 +61,9 @@ export function mountBrowserAutomationHandler(
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationDaemonClientHandler(
|
||||
@@ -111,7 +119,32 @@ async function handleBrowserAutomationRequest(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
await rememberAgentBrowserTarget({ request, browserHost });
|
||||
if (request.command.command === "resize") {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: resizeBrowserTabForRequest({ request, serverId }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.command.command === "close_tab") {
|
||||
try {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: await closeBrowserTabForRequest({
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!executeAutomationCommand) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
@@ -119,7 +152,7 @@ async function handleBrowserAutomationRequest(params: {
|
||||
payload: browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
message: "Browser automation is not available in this app runtime.",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -139,6 +172,127 @@ async function handleBrowserAutomationRequest(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function resizeBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
}): BrowserAutomationResponsePayload {
|
||||
const { request, serverId } = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "resize" }
|
||||
>;
|
||||
const browserId = command.args.browserId;
|
||||
if (!getBrowserRecord(browserId)) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceId = request.workspaceId;
|
||||
if (serverId && workspaceId && !findWorkspaceBrowserTab({ serverId, workspaceId, browserId })) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dimensions = resizeResidentBrowserWebview({
|
||||
browserId,
|
||||
width: command.args.width,
|
||||
height: command.args.height,
|
||||
});
|
||||
if (!dimensions) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: {
|
||||
command: "resize",
|
||||
browserId,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function closeBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
}): Promise<BrowserAutomationResponsePayload> {
|
||||
const { request, serverId, browserHost } = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "close_tab" }
|
||||
>;
|
||||
const browserId = command.args.browserId;
|
||||
const workspaceId = request.workspaceId;
|
||||
const workspaceTab = serverId
|
||||
? findWorkspaceBrowserTab({ serverId, workspaceId, browserId })
|
||||
: null;
|
||||
if (!workspaceTab && (!serverId || !workspaceId)) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Cannot close a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
if (!workspaceTab || !getBrowserRecord(browserId)) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_tab_not_found",
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
|
||||
useWorkspaceLayoutStore.getState().closeTab(workspaceTab.workspaceKey, workspaceTab.tabId);
|
||||
useBrowserStore.getState().removeBrowser(browserId);
|
||||
removeResidentBrowserWebview(browserId);
|
||||
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
|
||||
await browserHost?.clearPartition?.(browserId);
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: { command: "close_tab", browserId },
|
||||
};
|
||||
}
|
||||
|
||||
function findWorkspaceBrowserTab(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | undefined;
|
||||
browserId: string;
|
||||
}): { workspaceKey: string; tabId: string } | null {
|
||||
if (!input.workspaceId) {
|
||||
return null;
|
||||
}
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tab = layout
|
||||
? collectAllTabs(layout.root).find((candidate) => {
|
||||
return (
|
||||
candidate.target.kind === "browser" && candidate.target.browserId === input.browserId
|
||||
);
|
||||
})
|
||||
: null;
|
||||
return tab ? { workspaceKey, tabId: tab.tabId } : null;
|
||||
}
|
||||
|
||||
async function openBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
@@ -159,11 +313,11 @@ async function openBrowserTabForRequest(params: {
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "new_tab" }
|
||||
>;
|
||||
const workspaceId = request.workspaceId ?? command.args.workspaceId;
|
||||
const workspaceId = request.workspaceId;
|
||||
if (!serverId || !workspaceId) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_no_tab",
|
||||
code: "browser_unsupported",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
@@ -174,7 +328,7 @@ async function openBrowserTabForRequest(params: {
|
||||
if (!workspaceKey) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_no_tab",
|
||||
code: "browser_unsupported",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
@@ -184,9 +338,6 @@ async function openBrowserTabForRequest(params: {
|
||||
});
|
||||
|
||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
if (request.agentId) {
|
||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
||||
}
|
||||
|
||||
if (browserHost?.executeAutomationCommand) {
|
||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||
@@ -204,7 +355,7 @@ async function openBrowserTabForRequest(params: {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_timeout",
|
||||
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
|
||||
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
@@ -235,7 +386,7 @@ async function waitForBrowserRegistration(params: {
|
||||
agentId: params.request.agentId,
|
||||
cwd: params.request.cwd,
|
||||
workspaceId: params.workspaceId,
|
||||
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
|
||||
command: { command: "list_tabs", args: {} },
|
||||
});
|
||||
if (payload.ok && payload.result.command === "list_tabs") {
|
||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||
@@ -251,28 +402,6 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function rememberAgentBrowserTarget(input: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
}): Promise<void> {
|
||||
if (!input.request.agentId) {
|
||||
return;
|
||||
}
|
||||
const browserId = readRequestBrowserId(input.request);
|
||||
if (!browserId) {
|
||||
return;
|
||||
}
|
||||
await input.browserHost?.setAgentActiveBrowser?.({ agentId: input.request.agentId, browserId });
|
||||
}
|
||||
|
||||
function readRequestBrowserId(request: BrowserAutomationExecuteRequest): string | null {
|
||||
if (request.browserId) {
|
||||
return request.browserId;
|
||||
}
|
||||
const args = request.command.args as { browserId?: unknown };
|
||||
return typeof args.browserId === "string" && args.browserId.length > 0 ? args.browserId : null;
|
||||
}
|
||||
|
||||
function normalizeBridgePayload(
|
||||
requestId: string,
|
||||
payload: BrowserAutomationResponsePayload,
|
||||
@@ -294,14 +423,14 @@ function normalizeThrownBridgeError(
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||
message: "Browser automation is not implemented by this app build yet.",
|
||||
});
|
||||
}
|
||||
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unknown_error",
|
||||
message: message || "Desktop browser automation failed.",
|
||||
message: message || "Browser automation failed.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,21 +12,22 @@ import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Maximize,
|
||||
Monitor,
|
||||
MousePointer2,
|
||||
PencilRuler,
|
||||
RotateCw,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Wrench,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
@@ -40,11 +41,7 @@ import {
|
||||
useWorkspaceAttachments,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
BrowserAnnotationIntent,
|
||||
BrowserElementAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
|
||||
import { persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import {
|
||||
@@ -52,7 +49,6 @@ import {
|
||||
isElectronRuntime,
|
||||
type DesktopBrowserShortcutEvent,
|
||||
} from "@/desktop/host";
|
||||
import { isDev } from "@/constants/platform";
|
||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
||||
import {
|
||||
prepareBrowserWebview,
|
||||
@@ -79,25 +75,14 @@ type WebTextInput = TextInput & {
|
||||
getNativeRef?: () => unknown;
|
||||
};
|
||||
|
||||
type BrowserElementSelection = Omit<
|
||||
BrowserElementAttachment,
|
||||
"formatted" | "comment" | "intent"
|
||||
> & {
|
||||
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
interface BrowserElementAnnotation {
|
||||
comment: string;
|
||||
intent: BrowserAnnotationIntent;
|
||||
}
|
||||
|
||||
const BROWSER_ANNOTATION_INTENTS: readonly BrowserAnnotationIntent[] = [
|
||||
"fix",
|
||||
"change",
|
||||
"question",
|
||||
"approve",
|
||||
];
|
||||
|
||||
type DeviceSizeId =
|
||||
| "responsive"
|
||||
| "iphone-se"
|
||||
@@ -223,10 +208,6 @@ function formatElementAttachment(
|
||||
const html = truncateText(selection.outerHTML.trim(), 800);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (annotation) {
|
||||
parts.push(`intent: ${annotation.intent}`);
|
||||
}
|
||||
|
||||
if (selection.reactSource?.fileName) {
|
||||
const loc = [
|
||||
selection.reactSource.fileName,
|
||||
@@ -289,7 +270,6 @@ function buildBrowserElementAttachment(
|
||||
parentChain: selection.parentChain,
|
||||
children: selection.children,
|
||||
...(comment ? { comment } : {}),
|
||||
...(annotation ? { intent: annotation.intent } : {}),
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
formatted: formatElementAttachment(selection, annotation),
|
||||
};
|
||||
@@ -641,10 +621,11 @@ export function BrowserPane({
|
||||
const pendingNavigationUrlRef = useRef<string | null>(null);
|
||||
const domReadyRef = useRef(false);
|
||||
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
|
||||
const [selectorActive, setSelectorActive] = useState(false);
|
||||
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
|
||||
const selectorActive = selectorMode !== null;
|
||||
// Which action the active selector performs on click: open the annotation card
|
||||
// ("annotate") or copy the element to the clipboard ("grab").
|
||||
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
|
||||
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
|
||||
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
|
||||
const toast = useToast();
|
||||
const toastRef = useRef(toast);
|
||||
toastRef.current = toast;
|
||||
@@ -1074,7 +1055,7 @@ export function BrowserPane({
|
||||
[],
|
||||
);
|
||||
|
||||
const grabElementToClipboard = useCallback(
|
||||
const screenshotElementToClipboard = useCallback(
|
||||
async (selection: BrowserElementSelection) => {
|
||||
const text = formatElementAttachment(selection);
|
||||
const copyElement = getDesktopHost()?.browser?.copyElement;
|
||||
@@ -1087,19 +1068,23 @@ export function BrowserPane({
|
||||
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
|
||||
imageDataUrl = dataUrl ?? undefined;
|
||||
} catch (error) {
|
||||
console.warn("[browser-pane] capture element for grab failed", error);
|
||||
console.warn("[browser-pane] capture element for screenshot failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
const copiedMessage = imageDataUrl
|
||||
? t("workspace.browser.controls.screenshotCopied")
|
||||
: t("workspace.browser.controls.elementCopied");
|
||||
|
||||
// Copy via the main process; the renderer's navigator.clipboard rejects
|
||||
// with NotAllowedError because focus is inside the guest <webview>.
|
||||
if (typeof copyElement === "function") {
|
||||
try {
|
||||
const ok = await copyElement({ text, imageDataUrl });
|
||||
if (ok) {
|
||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
||||
toastRef.current?.show(copiedMessage, { variant: "success" });
|
||||
} else {
|
||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
||||
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -1110,10 +1095,12 @@ export function BrowserPane({
|
||||
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
|
||||
try {
|
||||
await Clipboard.setStringAsync(text);
|
||||
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
|
||||
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[browser-pane] clipboard fallback failed", error);
|
||||
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
|
||||
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
@@ -1121,8 +1108,8 @@ export function BrowserPane({
|
||||
|
||||
const handleSelectorResult = useCallback(
|
||||
(selection: BrowserElementSelection) => {
|
||||
if (selectorModeRef.current === "grab") {
|
||||
void grabElementToClipboard(selection);
|
||||
if (selectorModeRef.current === "screenshot") {
|
||||
void screenshotElementToClipboard(selection);
|
||||
return;
|
||||
}
|
||||
pendingScreenshotRef.current = undefined;
|
||||
@@ -1132,7 +1119,7 @@ export function BrowserPane({
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
[captureElementScreenshot, grabElementToClipboard],
|
||||
[captureElementScreenshot, screenshotElementToClipboard],
|
||||
);
|
||||
|
||||
const submitAnnotation = useCallback(
|
||||
@@ -1155,15 +1142,15 @@ export function BrowserPane({
|
||||
}, []);
|
||||
|
||||
const startElementSelector = useCallback(
|
||||
(mode: "annotate" | "grab") => {
|
||||
(mode: "annotate" | "screenshot") => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview || !domReadyRef.current) return;
|
||||
// Annotate needs a workspace scope to attach to; grab only copies.
|
||||
// Annotate needs a workspace scope to attach to; screenshot only copies.
|
||||
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
||||
selectorModeRef.current = mode;
|
||||
pendingScreenshotRef.current = undefined;
|
||||
setPendingSelection(null);
|
||||
setSelectorActive(true);
|
||||
setSelectorMode(mode);
|
||||
|
||||
const js = `
|
||||
(function() {
|
||||
@@ -1389,11 +1376,11 @@ export function BrowserPane({
|
||||
const poll = startSelectorResultPolling({
|
||||
webview,
|
||||
onSelection: handleSelectorResult,
|
||||
onDone: () => setSelectorActive(false),
|
||||
onDone: () => setSelectorMode(null),
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
window.clearInterval(poll);
|
||||
setSelectorActive(false);
|
||||
setSelectorMode(null);
|
||||
if (webviewRef.current !== webview || !domReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1402,10 +1389,10 @@ export function BrowserPane({
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
setSelectorActive(false);
|
||||
setSelectorMode(null);
|
||||
});
|
||||
} catch {
|
||||
setSelectorActive(false);
|
||||
setSelectorMode(null);
|
||||
}
|
||||
},
|
||||
[handleSelectorResult, workspaceAttachmentScopeKey],
|
||||
@@ -1413,7 +1400,7 @@ export function BrowserPane({
|
||||
|
||||
const cancelElementSelector = useCallback(() => {
|
||||
const webview = webviewRef.current;
|
||||
setSelectorActive(false);
|
||||
setSelectorMode(null);
|
||||
if (webview && domReadyRef.current) {
|
||||
try {
|
||||
clearWebviewSelector(webview);
|
||||
@@ -1470,12 +1457,12 @@ export function BrowserPane({
|
||||
startElementSelector("annotate");
|
||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||
|
||||
const handleToggleGrab = useCallback(() => {
|
||||
const handleToggleScreenshot = useCallback(() => {
|
||||
if (selectorActive) {
|
||||
cancelElementSelector();
|
||||
return;
|
||||
}
|
||||
startElementSelector("grab");
|
||||
startElementSelector("screenshot");
|
||||
}, [cancelElementSelector, selectorActive, startElementSelector]);
|
||||
|
||||
const handleOpenDevTools = useCallback(() => {
|
||||
@@ -1521,13 +1508,21 @@ export function BrowserPane({
|
||||
],
|
||||
[browser?.canGoForward],
|
||||
);
|
||||
const selectorIconButtonStyle = useCallback(
|
||||
const annotateIconButtonStyle = useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||
styles.iconButton,
|
||||
selectorActive && styles.selectorActiveButton,
|
||||
selectorMode === "annotate" && styles.selectorActiveButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
],
|
||||
[selectorActive],
|
||||
[selectorMode],
|
||||
);
|
||||
const screenshotIconButtonStyle = useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
|
||||
styles.iconButton,
|
||||
selectorMode === "screenshot" && styles.selectorActiveButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
],
|
||||
[selectorMode],
|
||||
);
|
||||
|
||||
const devicePreset = useMemo(
|
||||
@@ -1633,36 +1628,46 @@ export function BrowserPane({
|
||||
onSelect={setDeviceSizeId}
|
||||
triggerStyle={baseIconButtonStyle}
|
||||
/>
|
||||
{isDev ? (
|
||||
<ToolbarButton
|
||||
label={t("workspace.browser.controls.openDevTools")}
|
||||
onPress={handleOpenDevTools}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
label={t("workspace.browser.controls.openDevTools")}
|
||||
onPress={handleOpenDevTools}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
<Wrench size={16} color={theme.colors.foregroundMuted} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={
|
||||
selectorActive
|
||||
selectorMode === "annotate"
|
||||
? t("workspace.browser.controls.cancelSelector")
|
||||
: t("workspace.browser.controls.selectElement")
|
||||
: t("workspace.browser.controls.annotateElement")
|
||||
}
|
||||
active={selectorActive}
|
||||
active={selectorMode === "annotate"}
|
||||
onPress={handleToggleElementSelector}
|
||||
style={selectorIconButtonStyle}
|
||||
style={annotateIconButtonStyle}
|
||||
>
|
||||
<MousePointer2
|
||||
size={16}
|
||||
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
|
||||
color={
|
||||
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t("workspace.browser.controls.grabElement")}
|
||||
onPress={handleToggleGrab}
|
||||
style={baseIconButtonStyle}
|
||||
label={
|
||||
selectorMode === "screenshot"
|
||||
? t("workspace.browser.controls.cancelSelector")
|
||||
: t("workspace.browser.controls.screenshotElement")
|
||||
}
|
||||
active={selectorMode === "screenshot"}
|
||||
onPress={handleToggleScreenshot}
|
||||
style={screenshotIconButtonStyle}
|
||||
>
|
||||
<Copy size={16} color={theme.colors.foregroundMuted} />
|
||||
<Camera
|
||||
size={16}
|
||||
color={
|
||||
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1690,45 +1695,6 @@ export function BrowserPane({
|
||||
);
|
||||
}
|
||||
|
||||
const INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
||||
fix: "workspace.browser.annotate.intents.fix",
|
||||
change: "workspace.browser.annotate.intents.change",
|
||||
question: "workspace.browser.annotate.intents.question",
|
||||
approve: "workspace.browser.annotate.intents.approve",
|
||||
};
|
||||
|
||||
function IntentChip({
|
||||
active,
|
||||
intent,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
active: boolean;
|
||||
intent: BrowserAnnotationIntent;
|
||||
label: string;
|
||||
onSelect: (intent: BrowserAnnotationIntent) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(intent);
|
||||
}, [intent, onSelect]);
|
||||
const chipStyle = useMemo(() => [styles.intentChip, active && styles.intentChipActive], [active]);
|
||||
const textStyle = useMemo(
|
||||
() => [styles.intentChipText, active && styles.intentChipTextActive],
|
||||
[active],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
style={chipStyle}
|
||||
>
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserElementAnnotationCard({
|
||||
selection,
|
||||
onSubmit,
|
||||
@@ -1740,14 +1706,11 @@ function BrowserElementAnnotationCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [comment, setComment] = useState("");
|
||||
const [intent, setIntent] = useState<BrowserAnnotationIntent>("fix");
|
||||
const commentRef = useRef(comment);
|
||||
commentRef.current = comment;
|
||||
const intentRef = useRef(intent);
|
||||
intentRef.current = intent;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit({ comment: commentRef.current, intent: intentRef.current });
|
||||
onSubmit({ comment: commentRef.current });
|
||||
}, [onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1792,17 +1755,6 @@ function BrowserElementAnnotationCard({
|
||||
<Text numberOfLines={1} style={styles.annotationElement}>
|
||||
{elementLabel}
|
||||
</Text>
|
||||
<View style={styles.annotationIntents}>
|
||||
{BROWSER_ANNOTATION_INTENTS.map((option) => (
|
||||
<IntentChip
|
||||
key={option}
|
||||
active={option === intent}
|
||||
intent={option}
|
||||
label={t(INTENT_LABEL_KEYS[option])}
|
||||
onSelect={setIntent}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<ThemedAnnotationInput
|
||||
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
|
||||
autoFocus
|
||||
@@ -1814,24 +1766,12 @@ function BrowserElementAnnotationCard({
|
||||
value={comment}
|
||||
/>
|
||||
<View style={styles.annotationActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onCancel}
|
||||
style={styles.annotationSecondaryButton}
|
||||
>
|
||||
<Text style={styles.annotationSecondaryText}>
|
||||
{t("workspace.browser.annotate.cancel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={handleSubmit}
|
||||
style={styles.annotationPrimaryButton}
|
||||
>
|
||||
<Text style={styles.annotationPrimaryText}>
|
||||
{t("workspace.browser.annotate.submit")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Button variant="ghost" size="sm" onPress={onCancel}>
|
||||
{t("workspace.browser.annotate.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={handleSubmit}>
|
||||
{t("workspace.browser.annotate.submit")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1986,31 +1926,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
annotationElement: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
annotationIntents: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
intentChip: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
intentChipActive: {
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: `${String(theme.colors.accent)}20`,
|
||||
},
|
||||
intentChipText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
intentChipTextActive: {
|
||||
color: theme.colors.accent,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
annotationInput: {
|
||||
minHeight: 64,
|
||||
@@ -2029,27 +1945,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
annotationSecondaryButton: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
annotationSecondaryText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
annotationPrimaryButton: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
annotationPrimaryText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.accentForeground ?? "#ffffff",
|
||||
},
|
||||
unavailableState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -2,33 +2,66 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
prepareBrowserWebview,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "./browser-webview-resident";
|
||||
|
||||
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
|
||||
|
||||
function residentHost(): HTMLElement {
|
||||
const host = document.getElementById(RESIDENT_HOST_ID);
|
||||
if (!host) {
|
||||
throw new Error("Expected resident browser host");
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function expectPermanentHostParking(host: HTMLElement): void {
|
||||
expect(host.style.position).toBe("fixed");
|
||||
expect(host.style.left).toBe("0px");
|
||||
expect(host.style.top).toBe("0px");
|
||||
expect(host.style.width).toBe("1px");
|
||||
expect(host.style.height).toBe("1px");
|
||||
expect(host.style.overflow).toBe("hidden");
|
||||
expect(host.style.opacity).toBe("1");
|
||||
expect(host.style.pointerEvents).toBe("none");
|
||||
expect(host.style.display).toBe("block");
|
||||
expect(host.style.visibility).toBe("visible");
|
||||
expect(host.style.transform).toBe("");
|
||||
}
|
||||
|
||||
function expectResidentWebviewParking(webview: HTMLElement): void {
|
||||
expect(webview.style.display).toBe("inline-flex");
|
||||
expect(webview.style.flex).toBe("0 0 auto");
|
||||
expect(webview.style.width).toBe("1280px");
|
||||
expect(webview.style.height).toBe("800px");
|
||||
expect(webview.style.position).toBe("absolute");
|
||||
expect(webview.style.left).toBe("0px");
|
||||
expect(webview.style.top).toBe("0px");
|
||||
expect(webview.style.zIndex).toBe("0");
|
||||
}
|
||||
|
||||
describe("resident browser webviews", () => {
|
||||
afterEach(() => {
|
||||
clearResidentBrowserWebviewsForTests();
|
||||
});
|
||||
|
||||
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
|
||||
const host = document.createElement("div");
|
||||
it("parks a browser webview in the permanent paintable 1x1 host", () => {
|
||||
const visibleHost = document.createElement("div");
|
||||
const webview = document.createElement("webview");
|
||||
host.appendChild(webview);
|
||||
document.body.appendChild(host);
|
||||
visibleHost.appendChild(webview);
|
||||
document.body.appendChild(visibleHost);
|
||||
|
||||
releaseResidentBrowserWebview("browser-a", webview);
|
||||
|
||||
expect(host.children).toHaveLength(0);
|
||||
const host = residentHost();
|
||||
expect(visibleHost.children).toHaveLength(0);
|
||||
expect(Array.from(host.children)).toEqual([webview]);
|
||||
expect(webview.isConnected).toBe(true);
|
||||
expect(webview.style.width).toBe("1280px");
|
||||
expect(webview.style.height).toBe("800px");
|
||||
|
||||
const reused = takeResidentBrowserWebview("browser-a");
|
||||
|
||||
expect(reused).toBe(webview);
|
||||
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
|
||||
expectPermanentHostParking(host);
|
||||
expectResidentWebviewParking(webview);
|
||||
});
|
||||
|
||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||
@@ -44,6 +77,113 @@ describe("resident browser webviews", () => {
|
||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||
"https://example.com",
|
||||
);
|
||||
expectPermanentHostParking(residentHost());
|
||||
expectResidentWebviewParking(webview as HTMLElement);
|
||||
});
|
||||
|
||||
it("normalizes an existing resident host back to permanent parking", () => {
|
||||
const staleHost = document.createElement("div");
|
||||
staleHost.id = RESIDENT_HOST_ID;
|
||||
staleHost.style.left = "-20000px";
|
||||
staleHost.style.width = "1280px";
|
||||
staleHost.style.height = "800px";
|
||||
staleHost.style.opacity = "0";
|
||||
staleHost.style.display = "none";
|
||||
document.body.appendChild(staleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-stale-host",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(webview).not.toBeNull();
|
||||
expectPermanentHostParking(staleHost);
|
||||
expectResidentWebviewParking(webview as HTMLElement);
|
||||
});
|
||||
|
||||
it("normalizes an existing resident webview and its stale host before reusing them", () => {
|
||||
const staleHost = document.createElement("div");
|
||||
staleHost.id = RESIDENT_HOST_ID;
|
||||
staleHost.style.left = "-20000px";
|
||||
staleHost.style.width = "1280px";
|
||||
staleHost.style.height = "800px";
|
||||
staleHost.style.opacity = "0";
|
||||
staleHost.style.display = "none";
|
||||
|
||||
const staleWebview = document.createElement("webview");
|
||||
prepareBrowserWebview(staleWebview, {
|
||||
browserId: "browser-stale-child",
|
||||
initialUrl: "https://example.com",
|
||||
});
|
||||
staleWebview.style.display = "none";
|
||||
staleWebview.style.width = "1px";
|
||||
staleWebview.style.height = "1px";
|
||||
staleWebview.style.position = "fixed";
|
||||
staleWebview.style.left = "-20000px";
|
||||
staleHost.appendChild(staleWebview);
|
||||
document.body.appendChild(staleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-stale-child",
|
||||
url: "https://example.com/agent",
|
||||
});
|
||||
|
||||
expect(webview).toBe(staleWebview);
|
||||
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
|
||||
expectPermanentHostParking(staleHost);
|
||||
expectResidentWebviewParking(staleWebview);
|
||||
});
|
||||
|
||||
it("parks resident webviews as an overlapping stack", () => {
|
||||
const firstWebview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-first",
|
||||
url: "https://example.com/first",
|
||||
});
|
||||
const secondWebview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-second",
|
||||
url: "https://example.com/second",
|
||||
});
|
||||
|
||||
const host = residentHost();
|
||||
expect(firstWebview?.parentElement).toBe(host);
|
||||
expect(secondWebview?.parentElement).toBe(host);
|
||||
expectResidentWebviewParking(firstWebview as HTMLElement);
|
||||
expectResidentWebviewParking(secondWebview as HTMLElement);
|
||||
});
|
||||
|
||||
it("moves a resident webview into a visible pane without recreating the node", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-visible",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
const visibleWebview = takeResidentBrowserWebview("browser-visible");
|
||||
|
||||
expect(visibleWebview).toBe(webview);
|
||||
expect(webview?.style.position).toBe("");
|
||||
expect(webview?.style.left).toBe("");
|
||||
expect(webview?.style.top).toBe("");
|
||||
expect(webview?.style.zIndex).toBe("");
|
||||
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
||||
const visibleHost = document.createElement("div");
|
||||
const visibleWebview = document.createElement("webview");
|
||||
prepareBrowserWebview(visibleWebview, {
|
||||
browserId: "browser-visible-pane",
|
||||
initialUrl: "https://example.com",
|
||||
});
|
||||
visibleHost.appendChild(visibleWebview);
|
||||
document.body.appendChild(visibleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-visible-pane",
|
||||
url: "https://example.com/agent",
|
||||
});
|
||||
|
||||
expect(webview).toBe(visibleWebview);
|
||||
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes a resident webview when its browser tab closes", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||
|
||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
@@ -21,42 +22,84 @@ function readDocument(): Document | null {
|
||||
return typeof document === "undefined" ? null : document;
|
||||
}
|
||||
|
||||
function applyResidentHostParkingStyle(host: HTMLElement): void {
|
||||
// Parked browser webviews must remain paintable at all times; screenshot
|
||||
// correctness depends on the proven states in docs/browser-capture-harness.md.
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "0";
|
||||
host.style.top = "0";
|
||||
host.style.width = "1px";
|
||||
host.style.height = "1px";
|
||||
host.style.overflow = "hidden";
|
||||
host.style.opacity = "1";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.display = "block";
|
||||
host.style.zIndex = "";
|
||||
host.style.clipPath = "";
|
||||
host.style.visibility = "visible";
|
||||
host.style.transform = "";
|
||||
}
|
||||
|
||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||
if (existing) {
|
||||
applyResidentHostParkingStyle(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const host = ownerDocument.createElement("div");
|
||||
host.id = RESIDENT_BROWSER_HOST_ID;
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-20000px";
|
||||
host.style.top = "0";
|
||||
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||
host.style.overflow = "hidden";
|
||||
host.style.opacity = "0";
|
||||
host.style.pointerEvents = "none";
|
||||
applyResidentHostParkingStyle(host);
|
||||
ownerDocument.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
|
||||
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
||||
return element as HTMLElement;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyResidentWebviewStyle(webview: HTMLElement): void {
|
||||
webview.style.display = "block";
|
||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
|
||||
if (!browserId) {
|
||||
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
|
||||
}
|
||||
return (
|
||||
residentWebviewSizesByBrowserId.get(browserId) ?? {
|
||||
width: RESIDENT_VIEWPORT_WIDTH,
|
||||
height: RESIDENT_VIEWPORT_HEIGHT,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
|
||||
const dimensions = dimensionsForBrowser(browserId);
|
||||
webview.style.display = "inline-flex";
|
||||
webview.style.flex = "0 0 auto";
|
||||
webview.style.width = `${dimensions.width}px`;
|
||||
webview.style.height = `${dimensions.height}px`;
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
webview.style.position = "absolute";
|
||||
webview.style.left = "0";
|
||||
webview.style.top = "0";
|
||||
webview.style.marginTop = "0";
|
||||
webview.style.zIndex = "0";
|
||||
}
|
||||
|
||||
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
||||
webview.style.position = "";
|
||||
webview.style.left = "";
|
||||
webview.style.top = "";
|
||||
webview.style.marginTop = "";
|
||||
webview.style.zIndex = "";
|
||||
}
|
||||
|
||||
export function prepareBrowserWebview(
|
||||
@@ -88,11 +131,15 @@ export function ensureResidentBrowserWebview(input: {
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
||||
if (resident?.isConnected) {
|
||||
releaseResidentBrowserWebview(browserId, resident);
|
||||
return resident;
|
||||
}
|
||||
|
||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
||||
if (existing) {
|
||||
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
|
||||
releaseResidentBrowserWebview(browserId, existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -114,6 +161,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
@@ -129,10 +177,33 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||
applyResidentWebviewStyle(webview);
|
||||
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
||||
}
|
||||
|
||||
export function resizeResidentBrowserWebview(input: {
|
||||
browserId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { width: number; height: number } | null {
|
||||
const normalizedBrowserId = trimNonEmpty(input.browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
const width = Math.max(1, Math.round(input.width));
|
||||
const height = Math.max(1, Math.round(input.height));
|
||||
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
|
||||
|
||||
const ownerDocument = readDocument();
|
||||
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
||||
if (webview) {
|
||||
webview.style.width = `${width}px`;
|
||||
webview.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function removeResidentBrowserWebview(browserId: string): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
@@ -141,6 +212,7 @@ export function removeResidentBrowserWebview(browserId: string): void {
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||
resident?.remove();
|
||||
}
|
||||
|
||||
@@ -149,5 +221,6 @@ export function clearResidentBrowserWebviewsForTests(): void {
|
||||
webview.remove();
|
||||
}
|
||||
residentWebviewsByBrowserId.clear();
|
||||
residentWebviewSizesByBrowserId.clear();
|
||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, Server } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
HostStatusDotSlot,
|
||||
} from "@/components/hosts/host-picker";
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export interface HostFilterProps {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
triggerTestID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "All hosts / <host>" filter pill shared by the History and Schedules
|
||||
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
|
||||
* when only one host exists. Copies the History layout exactly.
|
||||
*/
|
||||
export function HostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
triggerTestID,
|
||||
}: HostFilterProps): ReactElement {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={selectedHost}
|
||||
onSelect={onSelectHost}
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID={triggerTestID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
||||
>
|
||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
||||
<ThemedServer size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<HostStatusDotSlot serverId={selectedHost} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filterTriggerWrap: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
}));
|
||||
@@ -1,20 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenRouterIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
createPaseoAgentProviderInput,
|
||||
getPaseoAgentApiKeyAuth,
|
||||
getPaseoAgentOAuthAuth,
|
||||
isPaseoAgentCatalogEntrySupported,
|
||||
nextPaseoAgentProviderName,
|
||||
parsePaseoAgentModelIds,
|
||||
paseoAgentAuthBadge,
|
||||
paseoAgentProviderLabel,
|
||||
preferredPaseoAgentOAuthMode,
|
||||
} from "./paseo-agent-settings-sheet-model";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("paseo-agent-settings-sheet-model", () => {
|
||||
it("recognizes supported catalog auth manifests", () => {
|
||||
const apiKeyEntry = catalogEntry({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
},
|
||||
});
|
||||
const oauthEntry = catalogEntry({
|
||||
auth: { kind: "oauth", flow: "alpha-oauth" },
|
||||
});
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
});
|
||||
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
|
||||
kind: "oauth",
|
||||
flow: "alpha-oauth",
|
||||
});
|
||||
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
|
||||
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps unknown catalog auth kinds visible but unsupported", () => {
|
||||
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
|
||||
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
|
||||
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers device-code OAuth only for a relay connection", () => {
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode(null)).toBe("browser");
|
||||
});
|
||||
|
||||
it("parses model ids from comma and newline separated input", () => {
|
||||
expect(
|
||||
parsePaseoAgentModelIds(`
|
||||
alpha/fast, beta/steady
|
||||
alpha/fast
|
||||
gamma/deep
|
||||
`),
|
||||
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
|
||||
});
|
||||
|
||||
it("uses the catalog label for the first provider instance", () => {
|
||||
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
|
||||
"Acme Cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("numbers later provider instances from the catalog label", () => {
|
||||
expect(
|
||||
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
|
||||
providerConfig({ name: "Acme Cloud" }),
|
||||
providerConfig({ name: "Acme Cloud (1)" }),
|
||||
]),
|
||||
).toBe("Acme Cloud (2)");
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with a trimmed explicit key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ id: "catalog-beta" }),
|
||||
name: " beta-main ",
|
||||
apiKey: " beta-secret ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-beta",
|
||||
options: {
|
||||
apiKey: "beta-secret",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with custom model ids", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
modelIds: ["alpha/fast", "beta/steady"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits model overrides when the catalog has no defaults", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with an env reference for an empty key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
|
||||
name: "beta-main",
|
||||
apiKey: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$BETA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an oauth provider payload without key material", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
|
||||
name: "alpha-login",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-login",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses catalog labels and generic auth badges for instance rows", () => {
|
||||
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
|
||||
expect(
|
||||
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
|
||||
).toBe("custom-alpha");
|
||||
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
|
||||
label: "Connected",
|
||||
variant: "success",
|
||||
});
|
||||
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
|
||||
label: "Needs attention",
|
||||
variant: "error",
|
||||
});
|
||||
expect(paseoAgentAuthBadge(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
export interface PaseoAgentApiKeyAuthManifest {
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuthManifest {
|
||||
kind: "oauth";
|
||||
flow: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentAuthBadge {
|
||||
label: string;
|
||||
variant: "success" | "error" | "muted";
|
||||
}
|
||||
|
||||
export type PaseoAgentOAuthMode = "browser" | "device_code";
|
||||
|
||||
export interface PaseoAgentOAuthConnectionSignal {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function preferredPaseoAgentOAuthMode(
|
||||
activeConnection: PaseoAgentOAuthConnectionSignal | null,
|
||||
): PaseoAgentOAuthMode {
|
||||
return activeConnection?.type === "relay" ? "device_code" : "browser";
|
||||
}
|
||||
|
||||
export function getPaseoAgentApiKeyAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentApiKeyAuthManifest | null {
|
||||
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "api_key",
|
||||
envVar: entry.auth.envVar,
|
||||
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
|
||||
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
|
||||
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaseoAgentOAuthAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentOAuthAuthManifest | null {
|
||||
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { kind: "oauth", flow: entry.auth.flow };
|
||||
}
|
||||
|
||||
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
|
||||
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
|
||||
}
|
||||
|
||||
export function paseoAgentProviderLabel(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
catalogEntry: PaseoAgentCatalogEntry | undefined,
|
||||
): string {
|
||||
return catalogEntry?.label ?? provider.providerType;
|
||||
}
|
||||
|
||||
export function paseoAgentAuthBadge(
|
||||
auth: RedactedPaseoAgentProviderConfig["auth"],
|
||||
): PaseoAgentAuthBadge | null {
|
||||
if (!auth || auth.kind === "none") {
|
||||
return null;
|
||||
}
|
||||
return auth.configured
|
||||
? { label: "Connected", variant: "success" }
|
||||
: { label: "Needs attention", variant: "error" };
|
||||
}
|
||||
|
||||
export function parsePaseoAgentModelIds(raw: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const part of raw.split(/[\n,]/)) {
|
||||
const id = part.trim();
|
||||
if (id.length > 0 && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function nextPaseoAgentProviderName(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
providers: RedactedPaseoAgentProviderConfig[],
|
||||
): string {
|
||||
const baseName = entry.label.trim() || entry.id;
|
||||
const existingNames = new Set(providers.map((provider) => provider.name));
|
||||
if (!existingNames.has(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = `${baseName} (${index})`;
|
||||
if (!existingNames.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPaseoAgentProviderInput(input: {
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
name: string;
|
||||
apiKey?: string;
|
||||
modelIds?: string[];
|
||||
}): PaseoAgentSetProviderInput {
|
||||
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
|
||||
const trimmedKey = input.apiKey?.trim() ?? "";
|
||||
let apiKey: string | undefined;
|
||||
if (apiKeyAuth) {
|
||||
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
|
||||
}
|
||||
const models =
|
||||
input.modelIds && input.modelIds.length > 0
|
||||
? input.modelIds.map((id) => ({ id }))
|
||||
: input.entry.models.map((model) => ({ ...model }));
|
||||
|
||||
return {
|
||||
name: input.name.trim(),
|
||||
providerType: input.entry.id,
|
||||
options: {
|
||||
...(models.length > 0 ? { models } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PaseoAgentOAuthCompleteResult,
|
||||
PaseoAgentOAuthStartResult,
|
||||
PaseoAgentSetProviderInput,
|
||||
} from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
interface PaseoAgentProvidersHookMock {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => {
|
||||
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
|
||||
current: {
|
||||
supported: true,
|
||||
catalogSupported: true,
|
||||
providers: [] as RedactedPaseoAgentProviderConfig[],
|
||||
catalog: [] as PaseoAgentCatalogEntry[],
|
||||
defaultModel: null as string | null,
|
||||
isLoading: false,
|
||||
isCatalogLoading: false,
|
||||
error: null as string | null,
|
||||
catalogError: null as string | null,
|
||||
refresh: vi.fn(async () => undefined),
|
||||
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
|
||||
startOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthStartResult> => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
completeOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
hookState: initialHookState,
|
||||
hostRuntimeSnapshot: {
|
||||
current: {
|
||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
||||
} as {
|
||||
activeConnection: { type: string; endpoint: string; display: string } | null;
|
||||
} | null,
|
||||
},
|
||||
openExternalUrls: [] as string[],
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
borderRadius: { md: 6, lg: 8 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
border: "#444",
|
||||
destructive: "#f00",
|
||||
statusSuccess: "#0f0",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({
|
||||
children,
|
||||
testID,
|
||||
role,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
role?: string;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": testID,
|
||||
role: role ?? accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
|
||||
React.createElement("span", { "data-testid": testID }, children),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Bot: icon("Bot"),
|
||||
Plus: icon("Plus"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
header,
|
||||
children,
|
||||
footer,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; back?: { label?: string; onPress: () => void } };
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.back ? (
|
||||
<button type="button" onClick={header.back.onPress}>
|
||||
{header.back.label ?? "Back"}
|
||||
</button>
|
||||
) : null}
|
||||
{children}
|
||||
{footer ? <footer>{footer}</footer> : null}
|
||||
</section>
|
||||
) : null,
|
||||
AdaptiveTextInput: ({
|
||||
value,
|
||||
onChangeText,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
placeholder,
|
||||
secureTextEntry,
|
||||
editable = true,
|
||||
multiline,
|
||||
}: {
|
||||
value?: string;
|
||||
onChangeText?: (value: string) => void;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
editable?: boolean;
|
||||
multiline?: boolean;
|
||||
}) => {
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
|
||||
onChangeText?.(event.currentTarget.value);
|
||||
}
|
||||
|
||||
const inputProps = {
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
disabled: !editable,
|
||||
onChange: handleChange,
|
||||
placeholder,
|
||||
value: value ?? "",
|
||||
};
|
||||
|
||||
if (multiline) {
|
||||
return React.createElement("textarea", inputProps);
|
||||
}
|
||||
|
||||
return React.createElement("input", {
|
||||
...inputProps,
|
||||
type: secureTextEntry ? "password" : "text",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icon-name", () => ({
|
||||
resolveProviderIconName: (provider: string) =>
|
||||
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/external-link", () => ({
|
||||
ExternalLink: ({
|
||||
href,
|
||||
label,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) => (
|
||||
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
|
||||
{label}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/status-badge", () => ({
|
||||
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
|
||||
usePaseoAgentProviders: () => hookState.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: async (url: string) => {
|
||||
openExternalUrls.push(url);
|
||||
},
|
||||
}));
|
||||
|
||||
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
iconName: "known-icon",
|
||||
docsUrl: "https://alpha.example.test/docs",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
auth: { kind: "api_key", configured: true },
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet() {
|
||||
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
|
||||
}
|
||||
|
||||
function resetHookState() {
|
||||
hookState.current.supported = true;
|
||||
hookState.current.catalogSupported = true;
|
||||
hookState.current.providers = [];
|
||||
hookState.current.catalog = [];
|
||||
hookState.current.defaultModel = null;
|
||||
hookState.current.isLoading = false;
|
||||
hookState.current.isCatalogLoading = false;
|
||||
hookState.current.error = null;
|
||||
hookState.current.catalogError = null;
|
||||
hookState.current.refresh = vi.fn(async () => undefined);
|
||||
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}));
|
||||
hookState.current.completeOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}));
|
||||
hostRuntimeSnapshot.current = {
|
||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
||||
};
|
||||
openExternalUrls.splice(0);
|
||||
}
|
||||
|
||||
describe("PaseoAgentSettingsSheet", () => {
|
||||
beforeEach(() => {
|
||||
resetHookState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows the catalog feature-gate message exactly once", () => {
|
||||
hookState.current.catalogSupported = false;
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ label: "Alpha Provider" }),
|
||||
catalogEntry({
|
||||
id: "catalog-future",
|
||||
label: "Future Provider",
|
||||
auth: { kind: "future_auth", prompt: "not yet" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
|
||||
expect(screen.getByText("Alpha Provider")).toBeTruthy();
|
||||
expect(screen.getByText("Future Provider")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
|
||||
"https://alpha.example.test/docs",
|
||||
);
|
||||
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("submits an api-key provider with an explicit key", async () => {
|
||||
hookState.current.catalog = [catalogEntry({ models: [] })];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
expect(screen.queryByLabelText("Provider name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Models")).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText("API key"), {
|
||||
target: { value: " alpha-secret " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits an empty api-key field as a host env reference", async () => {
|
||||
hookState.current.catalog = [catalogEntry({})];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://login.example.test/oauth/authorize?state=abc",
|
||||
instructions: "Open the sign-in page",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
|
||||
});
|
||||
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
|
||||
"https://login.example.test/oauth/authorize?state=abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a device-code oauth authorization and completes it", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "CODE-123",
|
||||
verificationUri: "https://login.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 600,
|
||||
instructions: "Enter the code",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use a code instead" }));
|
||||
|
||||
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
|
||||
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Login",
|
||||
providerType: "catalog-login",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
|
||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
|
||||
});
|
||||
});
|
||||
|
||||
it("offers device-code first over relay while keeping browser available", () => {
|
||||
hostRuntimeSnapshot.current = {
|
||||
activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" },
|
||||
};
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
|
||||
const actionButtons = screen
|
||||
.getByTestId("paseo-agent-oauth-sign-in")
|
||||
.querySelectorAll("button");
|
||||
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
|
||||
"Providers",
|
||||
"Cancel",
|
||||
"Use a code instead",
|
||||
"Sign in with browser",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows auth-state badges from configured instances", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
|
||||
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
|
||||
];
|
||||
hookState.current.providers = [
|
||||
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
|
||||
providerConfig({
|
||||
name: "beta",
|
||||
providerType: "catalog-beta",
|
||||
auth: { kind: "api_key", configured: false },
|
||||
}),
|
||||
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
expect(screen.getByText("Needs attention")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,17 +13,6 @@ describe("resolveProviderIconName", () => {
|
||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
||||
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
||||
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
|
||||
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
|
||||
});
|
||||
|
||||
it("resolves Paseo Agent model-provider catalog icon names", () => {
|
||||
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
|
||||
expect(resolveProviderIconName("openrouter")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "openrouter",
|
||||
});
|
||||
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
|
||||
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
|
||||
@@ -6,9 +6,7 @@ import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OpenRouterIcon } from "@/components/icons/openrouter-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import { resolveProviderIconName } from "@/components/provider-icon-name";
|
||||
@@ -27,10 +25,7 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
kiro: PackagePlus,
|
||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
openai: CodexIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
|
||||
paseo: PaseoLogo as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
|
||||
const PASEO_AGENT_PROVIDER = "paseo";
|
||||
|
||||
export function ProviderSettingsHost() {
|
||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||
const provider = useProviderSettingsStore((state) => state.provider);
|
||||
@@ -19,10 +16,6 @@ export function ProviderSettingsHost() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === PASEO_AGENT_PROVIDER) {
|
||||
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
describeCron,
|
||||
everyMsToParts,
|
||||
@@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
value={intervalUnit}
|
||||
onValueChange={handleUnitChange}
|
||||
options={UNIT_OPTIONS}
|
||||
style={styles.unitControl}
|
||||
testID="cadence-interval-unit"
|
||||
/>
|
||||
</View>
|
||||
@@ -283,8 +281,6 @@ function CronPresetChip({
|
||||
);
|
||||
}
|
||||
|
||||
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
@@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
// Both cadence segmented controls hug their options and stand at the form's
|
||||
// field height, so the interval row reads as input + toggle rather than a
|
||||
// full-width track with the controls floating inside it.
|
||||
// The mode toggle hugs its options at the left rather than stretching to a
|
||||
// full-width track; the interval row then reads as input + toggle.
|
||||
modeControl: {
|
||||
alignSelf: "flex-start",
|
||||
height: 44,
|
||||
},
|
||||
unitControl: {
|
||||
height: 44,
|
||||
},
|
||||
presetRow: {
|
||||
flexDirection: "row",
|
||||
@@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
chipHover: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
// Selected preset reads as a chosen surface, not a second accent fill
|
||||
// competing with the sheet's primary CTA.
|
||||
chipSelected: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
chipLabelSelected: {
|
||||
color: theme.colors.accentForeground,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
cronInput: {
|
||||
minHeight: 44,
|
||||
@@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: MONOSPACE_FONT,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
preview: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
error: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.destructive,
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
buildScheduleProjectTargets,
|
||||
PROJECT_OPTION_PREFIX,
|
||||
type ScheduleProjectTarget,
|
||||
} from "@/schedules/schedule-project-targets";
|
||||
import { validateCron } from "@/utils/schedule-format";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
@@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
|
||||
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
|
||||
const PROJECT_OPTION_PREFIX = "project:";
|
||||
|
||||
export interface ScheduleFormSheetProps {
|
||||
serverId?: string;
|
||||
@@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps {
|
||||
schedule?: ScheduleSummary;
|
||||
}
|
||||
|
||||
interface ScheduleProjectTarget {
|
||||
optionId: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ScheduleProjectOptions {
|
||||
targets: ScheduleProjectTarget[];
|
||||
options: ComboboxOption[];
|
||||
@@ -79,44 +75,19 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
||||
}
|
||||
|
||||
function buildProjectOptionTestId(optionId: string): string {
|
||||
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
|
||||
return `schedule-project-option-${targetKey}`;
|
||||
}
|
||||
|
||||
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
|
||||
const targets: ScheduleProjectTarget[] = [];
|
||||
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
|
||||
const options: ComboboxOption[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
const cwd = host.repoRoot.trim();
|
||||
if (!host.isOnline || !cwd) {
|
||||
continue;
|
||||
}
|
||||
const target: ScheduleProjectTarget = {
|
||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
cwd,
|
||||
};
|
||||
targets.push(target);
|
||||
targetByOptionId.set(target.optionId, target);
|
||||
options.push({
|
||||
id: target.optionId,
|
||||
label: target.projectName,
|
||||
description: `${target.serverName} - ${shortenPath(cwd)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const targets = buildScheduleProjectTargets(projects);
|
||||
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
|
||||
const options: ComboboxOption[] = targets.map((target) => ({
|
||||
id: target.optionId,
|
||||
label: target.projectName,
|
||||
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
|
||||
}));
|
||||
return { targets, options, targetByOptionId };
|
||||
}
|
||||
|
||||
@@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: {
|
||||
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
|
||||
}
|
||||
|
||||
function parseMaxRuns(raw: string): number | null {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function canSubmitScheduleForm(input: {
|
||||
isAgentTarget: boolean;
|
||||
isEdit: boolean;
|
||||
promptTrimmed: string;
|
||||
cadenceError: string | null;
|
||||
isSubmitting: boolean;
|
||||
selectedModelIsValid: boolean;
|
||||
hasWorkingDir: boolean;
|
||||
hasSelectedProject: boolean;
|
||||
}): boolean {
|
||||
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
|
||||
return false;
|
||||
}
|
||||
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
|
||||
// non-empty stored cwd; create requires a matched project.
|
||||
if (input.isAgentTarget) {
|
||||
return true;
|
||||
}
|
||||
if (!input.selectedModelIsValid) {
|
||||
return false;
|
||||
}
|
||||
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
|
||||
}
|
||||
|
||||
export function ScheduleFormSheet({
|
||||
serverId,
|
||||
visible,
|
||||
@@ -161,10 +161,26 @@ export function ScheduleFormSheet({
|
||||
schedule,
|
||||
}: ScheduleFormSheetProps): ReactElement {
|
||||
const isEdit = mode === "edit";
|
||||
const editConfig = newAgentConfig(schedule);
|
||||
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
|
||||
// (service.ts rejects newAgentConfig for them), so the form drops the
|
||||
// project/model/mode pickers and shows the target agent read-only instead.
|
||||
const isAgentTarget = isEdit && schedule?.target.type === "agent";
|
||||
const { projects } = useProjects();
|
||||
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
||||
|
||||
const agentTargetLabel = useMemo(() => {
|
||||
if (!schedule || schedule.target.type !== "agent") {
|
||||
return null;
|
||||
}
|
||||
const { agentId } = schedule.target;
|
||||
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
|
||||
if (!agent) {
|
||||
return "Agent unavailable";
|
||||
}
|
||||
return agent.title?.trim() || "Untitled agent";
|
||||
}, [agents, schedule, serverId]);
|
||||
|
||||
const onlineServerIds = useMemo(
|
||||
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
||||
[projectOptions.targets],
|
||||
@@ -197,7 +213,9 @@ export function ScheduleFormSheet({
|
||||
setProviderAndModelFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
setModeFromUser,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
modeOptions,
|
||||
modelSelectorProviders,
|
||||
@@ -219,7 +237,10 @@ export function ScheduleFormSheet({
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
(target: ScheduleProjectTarget) => {
|
||||
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
|
||||
// Compare against the current server, not the matched target: an unmatched
|
||||
// stored cwd has no target but still lives on a host, and switching hosts
|
||||
// must still clear a provider/model that may not exist on the new one.
|
||||
if (selectedServerId && selectedServerId !== target.serverId) {
|
||||
clearProviderSelectionFromUser();
|
||||
}
|
||||
setSelectedServerIdFromUser(target.serverId);
|
||||
@@ -227,7 +248,7 @@ export function ScheduleFormSheet({
|
||||
},
|
||||
[
|
||||
clearProviderSelectionFromUser,
|
||||
selectedProjectTarget,
|
||||
selectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setWorkingDirFromUser,
|
||||
],
|
||||
@@ -293,90 +314,127 @@ export function ScheduleFormSheet({
|
||||
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
||||
setSubmitError(null);
|
||||
setFieldResetKey((key) => key + 1);
|
||||
// The sheet stays mounted, and the form reducer's reset-on-close only
|
||||
// clears user-modified flags — not the picker values — so a create opened
|
||||
// after an edit would inherit that schedule's server/cwd (including a
|
||||
// stale ghost path). Clear them so create always starts fresh; provider
|
||||
// and model re-resolve from preferences.
|
||||
if (!isEdit) {
|
||||
setSelectedServerId(null);
|
||||
setWorkingDir("");
|
||||
}
|
||||
}
|
||||
wasVisibleRef.current = visible;
|
||||
}, [visible, schedule]);
|
||||
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
|
||||
|
||||
const promptTrimmed = prompt.trim();
|
||||
const trimmedWorkingDir = workingDir.trim();
|
||||
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
||||
const selectedModelIsValid = isSelectedModelValidForProviders({
|
||||
providers: modelSelectorProviders,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
});
|
||||
const canSubmit =
|
||||
promptTrimmed.length > 0 &&
|
||||
selectedModelIsValid &&
|
||||
Boolean(selectedProjectTarget) &&
|
||||
cadenceError === null &&
|
||||
!isSubmitting;
|
||||
const canSubmit = canSubmitScheduleForm({
|
||||
isAgentTarget,
|
||||
isEdit,
|
||||
promptTrimmed,
|
||||
cadenceError,
|
||||
isSubmitting,
|
||||
selectedModelIsValid,
|
||||
hasWorkingDir: trimmedWorkingDir.length > 0,
|
||||
hasSelectedProject: Boolean(selectedProjectTarget),
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
|
||||
return;
|
||||
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
|
||||
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||
if (!schedule) {
|
||||
return false;
|
||||
}
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await persistFormPreferences();
|
||||
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
|
||||
const maxRunsValue =
|
||||
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: name.trim() || null,
|
||||
prompt: promptTrimmed,
|
||||
cadence,
|
||||
maxRuns: parseMaxRuns(maxRuns),
|
||||
});
|
||||
return true;
|
||||
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
|
||||
|
||||
if (isEdit && schedule) {
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: name.trim() || null,
|
||||
prompt: promptTrimmed,
|
||||
cadence,
|
||||
newAgentConfig: {
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
modeId: selectedMode || null,
|
||||
cwd: selectedProjectTarget.cwd,
|
||||
},
|
||||
maxRuns: maxRunsValue,
|
||||
});
|
||||
} else {
|
||||
await createSchedule({
|
||||
prompt: promptTrimmed,
|
||||
name: name.trim() || undefined,
|
||||
cadence,
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: selectedProvider,
|
||||
cwd: selectedProjectTarget.cwd,
|
||||
model: selectedModel || undefined,
|
||||
modeId: selectedMode || undefined,
|
||||
thinkingOptionId: selectedThinkingOptionId || undefined,
|
||||
title: name.trim() || undefined,
|
||||
},
|
||||
},
|
||||
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setSubmitError(toErrorMessage(error));
|
||||
// New-agent target: submit the current working directory. On edit an untouched
|
||||
// picker leaves this as the stored cwd, so it round-trips unchanged.
|
||||
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedProvider || !trimmedWorkingDir) {
|
||||
return false;
|
||||
}
|
||||
await persistFormPreferences();
|
||||
const maxRunsValue = parseMaxRuns(maxRuns);
|
||||
if (isEdit && schedule) {
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: name.trim() || null,
|
||||
prompt: promptTrimmed,
|
||||
cadence,
|
||||
newAgentConfig: {
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
modeId: selectedMode || null,
|
||||
cwd: trimmedWorkingDir,
|
||||
},
|
||||
maxRuns: maxRunsValue,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await createSchedule({
|
||||
prompt: promptTrimmed,
|
||||
name: name.trim() || undefined,
|
||||
cadence,
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: selectedProvider,
|
||||
cwd: trimmedWorkingDir,
|
||||
model: selectedModel || undefined,
|
||||
modeId: selectedMode || undefined,
|
||||
thinkingOptionId: selectedThinkingOptionId || undefined,
|
||||
title: name.trim() || undefined,
|
||||
},
|
||||
},
|
||||
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
||||
});
|
||||
return true;
|
||||
}, [
|
||||
cadence,
|
||||
createSchedule,
|
||||
isEdit,
|
||||
maxRuns,
|
||||
name,
|
||||
onClose,
|
||||
persistFormPreferences,
|
||||
promptTrimmed,
|
||||
schedule,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProjectTarget,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
trimmedWorkingDir,
|
||||
updateSchedule,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!promptTrimmed) {
|
||||
return;
|
||||
}
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
|
||||
if (submitted) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitError(toErrorMessage(error));
|
||||
}
|
||||
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
|
||||
|
||||
const handleSubmitPress = useCallback(() => {
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
@@ -454,34 +512,53 @@ export function ScheduleFormSheet({
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<ProjectField
|
||||
options={projectOptions.options}
|
||||
targetByOptionId={projectOptions.targetByOptionId}
|
||||
value={selectedProjectOptionId}
|
||||
selectedTarget={selectedProjectTarget}
|
||||
onSelect={handleSelectProject}
|
||||
/>
|
||||
</View>
|
||||
{isAgentTarget ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Target</Text>
|
||||
<View style={styles.readonlyField} testID="schedule-agent-target">
|
||||
<Text style={styles.selectTriggerText} numberOfLines={1}>
|
||||
{agentTargetLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.hint}>Runs against this existing agent.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<ProjectField
|
||||
options={projectOptions.options}
|
||||
targetByOptionId={projectOptions.targetByOptionId}
|
||||
value={selectedProjectOptionId}
|
||||
selectedTarget={selectedProjectTarget}
|
||||
fallbackCwd={workingDir}
|
||||
onSelect={handleSelectProject}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Model</Text>
|
||||
<CombinedModelSelector
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={setProviderAndModelFromUser}
|
||||
isLoading={isAllModelsLoading}
|
||||
renderTrigger={renderModelTrigger}
|
||||
triggerFill
|
||||
serverId={mutationServerId}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Model</Text>
|
||||
<CombinedModelSelector
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={setProviderAndModelFromUser}
|
||||
isLoading={isAllModelsLoading}
|
||||
renderTrigger={renderModelTrigger}
|
||||
triggerFill
|
||||
serverId={mutationServerId}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{modeOptions.length > 0 ? (
|
||||
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
|
||||
) : null}
|
||||
{modeOptions.length > 0 ? (
|
||||
<ModeField
|
||||
options={modeOptions}
|
||||
selectedMode={selectedMode}
|
||||
onSelect={setModeFromUser}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Cadence</Text>
|
||||
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
|
||||
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
||||
</View>
|
||||
|
||||
{editConfig === null && isEdit ? (
|
||||
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
|
||||
) : null}
|
||||
|
||||
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
@@ -594,12 +667,15 @@ function ProjectField({
|
||||
targetByOptionId,
|
||||
value,
|
||||
selectedTarget,
|
||||
fallbackCwd,
|
||||
onSelect,
|
||||
}: {
|
||||
options: ComboboxOption[];
|
||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||
value: string;
|
||||
selectedTarget: ScheduleProjectTarget | null;
|
||||
/** Stored cwd for an edited schedule whose path matches no known project. */
|
||||
fallbackCwd: string;
|
||||
onSelect: (target: ScheduleProjectTarget) => void;
|
||||
}): ReactElement {
|
||||
const anchorRef = useRef<View>(null);
|
||||
@@ -629,7 +705,13 @@ function ProjectField({
|
||||
[open],
|
||||
);
|
||||
|
||||
const displayValue = selectedTarget?.projectName ?? "Select project";
|
||||
// Honest hydration: a stored cwd that matches no known project shows the
|
||||
// shortened path itself (not the blank "Select project"), and stays put until
|
||||
// the user deliberately picks a project.
|
||||
const storedPath = fallbackCwd.trim();
|
||||
const displayValue =
|
||||
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
|
||||
const isPlaceholder = !selectedTarget && !storedPath;
|
||||
const description = selectedTarget
|
||||
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
||||
: null;
|
||||
@@ -662,7 +744,7 @@ function ProjectField({
|
||||
testID="schedule-project-trigger"
|
||||
>
|
||||
<Text
|
||||
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
||||
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayValue}
|
||||
@@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.destructive,
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
readonlyField: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
minHeight: 44,
|
||||
},
|
||||
selectTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||
@@ -53,43 +55,62 @@ export interface ScheduleRowActions {
|
||||
|
||||
interface ScheduleRowProps extends ScheduleRowActions {
|
||||
schedule: ScheduleSummary;
|
||||
/** Client-derived target line (agent title / project / shortened path). */
|
||||
targetLabel: string;
|
||||
/** Provider glyph, resolved from the schedule config or the target agent. */
|
||||
provider: string | null;
|
||||
/** Client-derived state — the single source for the badge and next-run copy. */
|
||||
state: ScheduleDerivedState;
|
||||
/** Host name, rendered when the list spans more than one host. */
|
||||
serverName?: string;
|
||||
/** True when only one host exists and the host name would be redundant. */
|
||||
singleHost?: boolean;
|
||||
pending?: ScheduleRowPending;
|
||||
isFirst: boolean;
|
||||
}
|
||||
|
||||
function resolveProvider(schedule: ScheduleSummary): string | null {
|
||||
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
|
||||
function stateBadge(state: ScheduleDerivedState): {
|
||||
label: string;
|
||||
variant: "success" | "error" | "muted";
|
||||
} {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return { label: "Active", variant: "success" };
|
||||
case "paused":
|
||||
return { label: "Paused", variant: "muted" };
|
||||
case "expired":
|
||||
return { label: "Expired", variant: "muted" };
|
||||
case "finished":
|
||||
return { label: "Finished", variant: "muted" };
|
||||
case "targetGone":
|
||||
return { label: "Target gone", variant: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveModelLabel(schedule: ScheduleSummary): string {
|
||||
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
|
||||
return schedule.target.config.model;
|
||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||
// was created, when it last ran, and (only while it can still run) when it runs
|
||||
// next. Status lives on the badge, never repeated here.
|
||||
function buildMeta(
|
||||
schedule: ScheduleSummary,
|
||||
state: ScheduleDerivedState,
|
||||
serverName: string | undefined,
|
||||
singleHost: boolean,
|
||||
): string {
|
||||
const parts = [
|
||||
formatCadence(schedule.cadence),
|
||||
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||
];
|
||||
if (state === "active") {
|
||||
const next = formatNextRun(schedule.nextRunAt);
|
||||
if (next) {
|
||||
parts.push(`Next run ${next}`);
|
||||
}
|
||||
}
|
||||
return "Default model";
|
||||
}
|
||||
|
||||
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
|
||||
return status === "active" ? "success" : "muted";
|
||||
}
|
||||
|
||||
function statusLabel(status: ScheduleSummary["status"]): string {
|
||||
if (status === "active") {
|
||||
return "Active";
|
||||
if (serverName && !singleHost) {
|
||||
parts.unshift(serverName);
|
||||
}
|
||||
if (status === "paused") {
|
||||
return "Paused";
|
||||
}
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
function nextRunLabel(schedule: ScheduleSummary): string {
|
||||
if (schedule.status === "paused") {
|
||||
return "Paused";
|
||||
}
|
||||
if (schedule.status === "completed") {
|
||||
return "Completed";
|
||||
}
|
||||
return formatNextRun(schedule.nextRunAt) || "—";
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
@@ -113,6 +134,11 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
|
||||
*/
|
||||
export function ScheduleRow({
|
||||
schedule,
|
||||
targetLabel,
|
||||
provider,
|
||||
state,
|
||||
serverName,
|
||||
singleHost,
|
||||
pending,
|
||||
isFirst,
|
||||
onEdit,
|
||||
@@ -126,15 +152,10 @@ export function ScheduleRow({
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const provider = resolveProvider(schedule);
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const meta = [
|
||||
resolveModelLabel(schedule),
|
||||
formatCadence(schedule.cadence),
|
||||
nextRunLabel(schedule),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = state === "active" || state === "paused";
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -168,6 +189,9 @@ export function ScheduleRow({
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.target} numberOfLines={1}>
|
||||
{targetLabel}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
@@ -175,12 +199,10 @@ export function ScheduleRow({
|
||||
</View>
|
||||
|
||||
<View style={styles.trailing}>
|
||||
<StatusBadge
|
||||
label={statusLabel(schedule.status)}
|
||||
variant={statusVariant(schedule.status)}
|
||||
/>
|
||||
<StatusBadge label={badge.label} variant={badge.variant} />
|
||||
<ScheduleKebabMenu
|
||||
schedule={schedule}
|
||||
canRun={canRun}
|
||||
pending={pending}
|
||||
onEdit={onEdit}
|
||||
onPause={onPause}
|
||||
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
|
||||
|
||||
function ScheduleKebabMenu({
|
||||
schedule,
|
||||
canRun,
|
||||
pending,
|
||||
onEdit,
|
||||
onPause,
|
||||
onResume,
|
||||
onRunNow,
|
||||
onDelete,
|
||||
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
|
||||
}: Pick<
|
||||
ScheduleRowProps,
|
||||
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
|
||||
> & {
|
||||
canRun: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
|
||||
{schedule.status === "paused" ? (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed"}
|
||||
disabled={schedule.status === "completed" || !canRun}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
target: {
|
||||
marginTop: theme.spacing[1],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -3,44 +3,48 @@ import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
/** A schedule plus the client-derived fields the row renders. */
|
||||
export interface ScheduleRowView {
|
||||
schedule: AggregatedSchedule;
|
||||
targetLabel: string;
|
||||
provider: string | null;
|
||||
state: ScheduleDerivedState;
|
||||
serverName: string;
|
||||
/** True when only one host exists, so the host name is redundant in rows. */
|
||||
singleHost: boolean;
|
||||
}
|
||||
|
||||
interface SchedulesTableProps {
|
||||
serverId: string;
|
||||
schedules: ScheduleSummary[];
|
||||
rows: ScheduleRowView[];
|
||||
/**
|
||||
* The form sheet is owned by the screen (it serves both create and edit and
|
||||
* shares the header's "New schedule" button), so the table delegates edit
|
||||
* shares the screen's "New schedule" button), so the table delegates edit
|
||||
* upward rather than mounting a second sheet here.
|
||||
*/
|
||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
||||
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedules list: a single settings-style card of rows in a centered,
|
||||
* width-constrained reading column, matching the projects list. Owns row-level
|
||||
* actions (pause/resume/run/delete via the mutations hook + a destructive
|
||||
* confirm for delete) and delegates editing to the parent.
|
||||
* The schedules list: a single settings-style card of rows across every
|
||||
* connected host, in a full-width list matching the History screen. Rows own
|
||||
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
|
||||
* a destructive confirm) and delegate editing upward.
|
||||
*/
|
||||
export function SchedulesTable({
|
||||
serverId,
|
||||
schedules,
|
||||
onEditSchedule,
|
||||
}: SchedulesTableProps): ReactElement {
|
||||
const mutations = useScheduleMutations({ serverId });
|
||||
|
||||
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.listContent} testID="schedules-table">
|
||||
<View style={settingsStyles.card}>
|
||||
{schedules.map((schedule, index) => (
|
||||
{rows.map((row, index) => (
|
||||
<SchedulesTableRow
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
key={`${row.schedule.serverId}:${row.schedule.id}`}
|
||||
row={row}
|
||||
isFirst={index === 0}
|
||||
mutations={mutations}
|
||||
onEditSchedule={onEditSchedule}
|
||||
/>
|
||||
))}
|
||||
@@ -50,28 +54,26 @@ export function SchedulesTable({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-row wrapper owns local in-flight state and binds the table's mutation
|
||||
// callbacks to this schedule. Local state keeps pending precise to the acting
|
||||
// row even when several rows are acted on at once (the mutations hook exposes
|
||||
// only a single global pending flag per action).
|
||||
// Per-row wrapper owns local in-flight state and binds mutations to this
|
||||
// schedule's host. Local state keeps pending precise to the acting row even
|
||||
// when several rows are acted on at once (the mutations hook exposes only a
|
||||
// single global pending flag per action).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
|
||||
|
||||
const NO_PENDING: ScheduleRowPending = {};
|
||||
|
||||
function SchedulesTableRow({
|
||||
schedule,
|
||||
row,
|
||||
isFirst,
|
||||
mutations,
|
||||
onEditSchedule,
|
||||
}: {
|
||||
schedule: ScheduleSummary;
|
||||
row: ScheduleRowView;
|
||||
isFirst: boolean;
|
||||
mutations: ScheduleMutations;
|
||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
||||
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||
}): ReactElement {
|
||||
const { id } = schedule;
|
||||
const { schedule } = row;
|
||||
const { id, serverId } = schedule;
|
||||
const mutations = useScheduleMutations({ serverId });
|
||||
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
||||
|
||||
const runAction = useCallback(
|
||||
@@ -127,6 +129,11 @@ function SchedulesTableRow({
|
||||
return (
|
||||
<ScheduleRow
|
||||
schedule={schedule}
|
||||
targetLabel={row.targetLabel}
|
||||
provider={row.provider}
|
||||
state={row.state}
|
||||
serverName={row.serverName}
|
||||
singleHost={row.singleHost}
|
||||
isFirst={isFirst}
|
||||
pending={pending}
|
||||
onEdit={handleEdit}
|
||||
@@ -138,14 +145,9 @@ function SchedulesTableRow({
|
||||
);
|
||||
}
|
||||
|
||||
const CONTENT_MAX_WIDTH = 720;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Center the card in a readable column, matching settings and projects.
|
||||
// Full-width list padding matching the History screen.
|
||||
listContent: {
|
||||
width: "100%",
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
import {
|
||||
@@ -1881,6 +1882,7 @@ function ProjectBlock({
|
||||
activeWorkspaceSelection,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
@@ -1902,14 +1904,16 @@ function ProjectBlock({
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
}) {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
project,
|
||||
collapsed,
|
||||
supportsMultiplicityByServerId,
|
||||
}),
|
||||
[collapsed, project],
|
||||
[collapsed, project, supportsMultiplicityByServerId],
|
||||
);
|
||||
|
||||
const active = isProjectSelectedByRoute({
|
||||
@@ -2062,7 +2066,7 @@ function ProjectBlock({
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
);
|
||||
} else if (rowModel.trailingAction.kind === "new_worktree") {
|
||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||
projectChildren = (
|
||||
<NewWorkspaceGhostRow
|
||||
project={project}
|
||||
@@ -2085,7 +2089,7 @@ function ProjectBlock({
|
||||
chevron={rowModel.chevron}
|
||||
onPress={handleToggleCollapsed}
|
||||
worktreeTarget={
|
||||
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
|
||||
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
|
||||
}
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -2106,6 +2110,7 @@ function ProjectBlock({
|
||||
|
||||
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
|
||||
// oxlint-disable-next-line complexity
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
@@ -2117,6 +2122,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||
previous.showHostLabels === next.showHostLabels &&
|
||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
@@ -2183,6 +2189,8 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
return labels;
|
||||
}, [hosts]);
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||
|
||||
const content =
|
||||
@@ -2208,6 +2216,7 @@ export function SidebarWorkspaceList({
|
||||
pathname={pathname}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2256,6 +2265,7 @@ function ProjectModeList({
|
||||
pathname,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
@@ -2263,6 +2273,7 @@ function ProjectModeList({
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
@@ -2451,6 +2462,7 @@ function ProjectModeList({
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -2461,6 +2473,7 @@ function ProjectModeList({
|
||||
handleWorkspaceReorder,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
|
||||
@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
sm: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
|
||||
@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentSm: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
segmentMd: {
|
||||
|
||||
@@ -11,7 +11,6 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
|
||||
tag: attachment.attachment.tag,
|
||||
text: attachment.attachment.text,
|
||||
html: attachment.attachment.outerHTML,
|
||||
intent: attachment.attachment.intent ?? null,
|
||||
comment: attachment.attachment.comment ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.31.0",
|
||||
version: "0.32.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.34",
|
||||
version: "3.0.37",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.34", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.37", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.114.0",
|
||||
version: "2.116.0",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codewhale",
|
||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.12",
|
||||
version: "0.2.19",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.12",
|
||||
version: "0.4.13",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.161.0",
|
||||
version: "0.164.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.8.1",
|
||||
version: "0.8.3",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.22",
|
||||
version: "1.1.24",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.33",
|
||||
version: "1.0.37",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.19.3",
|
||||
version: "0.19.6",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
|
||||
description: "ByteDance's official TRAE coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: "traecli",
|
||||
installLink: "https://docs.trae.cn/cli",
|
||||
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
|
||||
command: ["traecli", "acp", "serve"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -128,11 +128,11 @@ export interface DesktopBrowserNewTabRequestEvent {
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
||||
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
|
||||
setWorkspaceActiveBrowser?: (input: {
|
||||
workspaceId: string;
|
||||
browserId: string | null;
|
||||
}) => Promise<void>;
|
||||
setAgentActiveBrowser?: (input: { agentId: string; browserId: string | null }) => Promise<void>;
|
||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||
clearPartition?: (browserId: string) => Promise<void>;
|
||||
executeAutomationCommand?: (
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentSetProviderRequest,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export function paseoAgentProvidersQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-providers", serverId] as const;
|
||||
}
|
||||
|
||||
export function paseoAgentCatalogQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-catalog", serverId] as const;
|
||||
}
|
||||
|
||||
function describeQueryError(error: unknown): string | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
|
||||
export type PaseoAgentRenameProviderInput = Omit<
|
||||
PaseoAgentRenameProviderRequest,
|
||||
"type" | "requestId"
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
|
||||
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
|
||||
|
||||
interface UsePaseoAgentProvidersResult {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
renameProvider: (
|
||||
input: PaseoAgentRenameProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected");
|
||||
const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle");
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
|
||||
);
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
|
||||
const catalogSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
|
||||
);
|
||||
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
|
||||
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(supported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentProviders();
|
||||
},
|
||||
});
|
||||
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: catalogQueryKey,
|
||||
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentCatalog();
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
|
||||
}, [catalogQueryKey, queryClient, queryKey]);
|
||||
|
||||
const error = query.data?.error ?? describeQueryError(query.error);
|
||||
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
|
||||
|
||||
const setProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentSetProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.setPaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: setProviderAsync } = setProviderMutation;
|
||||
|
||||
const setProvider = useCallback(
|
||||
(input: PaseoAgentSetProviderInput) => setProviderAsync(input),
|
||||
[setProviderAsync],
|
||||
);
|
||||
|
||||
const renameProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.renamePaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
|
||||
|
||||
const renameProvider = useCallback(
|
||||
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
|
||||
[renameProviderAsync],
|
||||
);
|
||||
|
||||
const startOAuthMutation = useMutation({
|
||||
mutationFn: async (input: { name: string; mode?: string }) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
|
||||
|
||||
const startOAuth = useCallback(
|
||||
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
|
||||
[startOAuthAsync],
|
||||
);
|
||||
|
||||
const completeOAuthMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.completePaseoAgentOAuth(name);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
|
||||
|
||||
const completeOAuth = useCallback(
|
||||
(name: string) => completeOAuthAsync(name),
|
||||
[completeOAuthAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
supported,
|
||||
catalogSupported,
|
||||
providers: query.data?.providers ?? [],
|
||||
catalog: catalogQuery.data?.catalog ?? [],
|
||||
defaultModel: query.data?.defaultModel ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isCatalogLoading: catalogQuery.isLoading,
|
||||
error,
|
||||
catalogError,
|
||||
refresh,
|
||||
setProvider,
|
||||
renameProvider,
|
||||
startOAuth,
|
||||
completeOAuth,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
||||
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
|
||||
import type {
|
||||
AggregatedSchedule,
|
||||
FetchAggregatedSchedulesResult,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||
@@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
|
||||
}
|
||||
}
|
||||
|
||||
function updateScheduleSections(
|
||||
function updateSchedulesData(
|
||||
queryClient: QueryClient,
|
||||
updateSection: (
|
||||
section: FetchAggregatedSchedulesResult["sections"][number],
|
||||
) => FetchAggregatedSchedulesResult["sections"][number],
|
||||
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||
): void {
|
||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||
{ queryKey: schedulesQueryBaseKey },
|
||||
@@ -72,7 +73,7 @@ function updateScheduleSections(
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return { sections: current.sections.map(updateSection) };
|
||||
return { ...current, schedules: updateSchedules(current.schedules) };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -84,26 +85,18 @@ function optimisticallySetStatus(
|
||||
status: ScheduleSummary["status"],
|
||||
): void {
|
||||
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
||||
updateScheduleSections(queryClient, (section) =>
|
||||
section.serverId === serverId
|
||||
? {
|
||||
...section,
|
||||
schedules: section.schedules.map((schedule) =>
|
||||
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
|
||||
),
|
||||
}
|
||||
: section,
|
||||
updateSchedulesData(queryClient, (schedules) =>
|
||||
schedules.map((schedule) =>
|
||||
schedule.serverId === serverId && schedule.id === id
|
||||
? { ...schedule, status, pausedAt }
|
||||
: schedule,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
||||
updateScheduleSections(queryClient, (section) =>
|
||||
section.serverId === serverId
|
||||
? {
|
||||
...section,
|
||||
schedules: section.schedules.filter((schedule) => schedule.id !== id),
|
||||
}
|
||||
: section,
|
||||
updateSchedulesData(queryClient, (schedules) =>
|
||||
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
fetchAggregatedSchedules,
|
||||
type AggregatedSchedule,
|
||||
type ScheduleHostError,
|
||||
type ScheduleHostInput,
|
||||
type ScheduleHostSection,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
|
||||
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
|
||||
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
|
||||
|
||||
export const schedulesQueryBaseKey = ["schedules"] as const;
|
||||
|
||||
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
|
||||
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
|
||||
// Cache identity for the host set. The query also carries the runtime version
|
||||
// (below) so it retries as connectivity changes and reliably fetches once a host
|
||||
// comes online — even on a cold deep-link. The full-screen spinner flash that
|
||||
// keying on the version used to cause is prevented by keepPreviousData plus the
|
||||
// isInitialLoad(data === undefined) gate, not by dropping the version.
|
||||
export function schedulesQueryKey(serverIds: readonly string[]) {
|
||||
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||
}
|
||||
|
||||
export interface UseSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
schedules: AggregatedSchedule[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
isInitialLoad: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
@@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult {
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
|
||||
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||
staleTime: 5_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
return {
|
||||
sections: query.data?.sections ?? [],
|
||||
isLoading: query.isLoading,
|
||||
schedules: query.data?.schedules ?? [],
|
||||
hostErrors: query.data?.hostErrors ?? [],
|
||||
isInitialLoad: query.isLoading && query.data === undefined,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: () => {
|
||||
|
||||
@@ -422,22 +422,17 @@ export const ar: TranslationResources = {
|
||||
enterUrl: "أدخل URL",
|
||||
openDevTools: "افتح أدوات تطوير المتصفح",
|
||||
cancelSelector: "إلغاء محدد العنصر",
|
||||
selectElement: "حدد العنصر",
|
||||
grabElement: "نسخ العنصر إلى الحافظة",
|
||||
grabElementLabel: "العنصر",
|
||||
grabFailed: "تعذّر نسخ العنصر",
|
||||
annotateElement: "التعليق على العنصر",
|
||||
screenshotElement: "لقطة للعنصر",
|
||||
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
|
||||
elementCopied: "تم نسخ العنصر إلى الحافظة",
|
||||
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
|
||||
},
|
||||
annotate: {
|
||||
title: "إرسال ملاحظات إلى الوكيل",
|
||||
placeholder: "صف ما الذي ينبغي تغييره…",
|
||||
title: "التعليق على العنصر",
|
||||
placeholder: "رسالة إلى الوكيل حول هذا العنصر…",
|
||||
submit: "إرفاق",
|
||||
cancel: "إلغاء",
|
||||
intents: {
|
||||
fix: "إصلاح",
|
||||
change: "تغيير",
|
||||
question: "سؤال",
|
||||
approve: "موافقة",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "حجم الجهاز",
|
||||
|
||||
@@ -422,22 +422,17 @@ export const en = {
|
||||
enterUrl: "Enter URL",
|
||||
openDevTools: "Open browser dev tools",
|
||||
cancelSelector: "Cancel element selector",
|
||||
selectElement: "Select element",
|
||||
grabElement: "Copy element to clipboard",
|
||||
grabElementLabel: "element",
|
||||
grabFailed: "Couldn't copy element",
|
||||
annotateElement: "Annotate element",
|
||||
screenshotElement: "Screenshot element",
|
||||
screenshotCopied: "Copied screenshot to clipboard",
|
||||
elementCopied: "Copied element to clipboard",
|
||||
screenshotFailed: "Couldn't copy screenshot",
|
||||
},
|
||||
annotate: {
|
||||
title: "Send feedback to agent",
|
||||
placeholder: "Describe what should change…",
|
||||
title: "Annotate element",
|
||||
placeholder: "Message to the agent about this element…",
|
||||
submit: "Attach",
|
||||
cancel: "Cancel",
|
||||
intents: {
|
||||
fix: "Fix",
|
||||
change: "Change",
|
||||
question: "Question",
|
||||
approve: "Approve",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Device size",
|
||||
|
||||
@@ -426,22 +426,17 @@ export const es: TranslationResources = {
|
||||
enterUrl: "IngreseURL",
|
||||
openDevTools: "Abrir herramientas de desarrollo del navegador",
|
||||
cancelSelector: "Cancelar selector de elementos",
|
||||
selectElement: "Seleccionar elemento",
|
||||
grabElement: "Copiar elemento al portapapeles",
|
||||
grabElementLabel: "elemento",
|
||||
grabFailed: "No se pudo copiar el elemento",
|
||||
annotateElement: "Anotar elemento",
|
||||
screenshotElement: "Capturar elemento",
|
||||
screenshotCopied: "Captura copiada al portapapeles",
|
||||
elementCopied: "Elemento copiado al portapapeles",
|
||||
screenshotFailed: "No se pudo copiar la captura",
|
||||
},
|
||||
annotate: {
|
||||
title: "Enviar comentarios al agente",
|
||||
placeholder: "Describe qué debería cambiar…",
|
||||
title: "Anotar elemento",
|
||||
placeholder: "Mensaje al agente sobre este elemento…",
|
||||
submit: "Adjuntar",
|
||||
cancel: "Cancelar",
|
||||
intents: {
|
||||
fix: "Corregir",
|
||||
change: "Cambiar",
|
||||
question: "Pregunta",
|
||||
approve: "Aprobar",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Tamaño del dispositivo",
|
||||
|
||||
@@ -426,22 +426,17 @@ export const fr: TranslationResources = {
|
||||
enterUrl: "EntrezURL",
|
||||
openDevTools: "Outils de développement du navigateur ouvert",
|
||||
cancelSelector: "Annuler le sélecteur d'élément",
|
||||
selectElement: "Sélectionner un élément",
|
||||
grabElement: "Copier l'élément dans le presse-papiers",
|
||||
grabElementLabel: "élément",
|
||||
grabFailed: "Impossible de copier l'élément",
|
||||
annotateElement: "Annoter l'élément",
|
||||
screenshotElement: "Capturer l'élément",
|
||||
screenshotCopied: "Capture d'écran copiée dans le presse-papiers",
|
||||
elementCopied: "Élément copié dans le presse-papiers",
|
||||
screenshotFailed: "Impossible de copier la capture",
|
||||
},
|
||||
annotate: {
|
||||
title: "Envoyer un retour à l'agent",
|
||||
placeholder: "Décrivez ce qui doit changer…",
|
||||
title: "Annoter l'élément",
|
||||
placeholder: "Message à l'agent concernant cet élément…",
|
||||
submit: "Joindre",
|
||||
cancel: "Annuler",
|
||||
intents: {
|
||||
fix: "Corriger",
|
||||
change: "Modifier",
|
||||
question: "Question",
|
||||
approve: "Approuver",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Taille de l'appareil",
|
||||
|
||||
@@ -426,22 +426,17 @@ export const ja: TranslationResources = {
|
||||
enterUrl: "URLを入力",
|
||||
openDevTools: "ブラウザ開発ツールを開く",
|
||||
cancelSelector: "要素セレクターをキャンセル",
|
||||
selectElement: "要素を選択",
|
||||
grabElement: "要素をクリップボードにコピー",
|
||||
grabElementLabel: "要素",
|
||||
grabFailed: "要素をコピーできませんでした",
|
||||
annotateElement: "要素に注釈を付ける",
|
||||
screenshotElement: "要素のスクリーンショット",
|
||||
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
|
||||
elementCopied: "要素をクリップボードにコピーしました",
|
||||
screenshotFailed: "スクリーンショットをコピーできませんでした",
|
||||
},
|
||||
annotate: {
|
||||
title: "エージェントにフィードバックを送信",
|
||||
placeholder: "変更すべき内容を記述…",
|
||||
title: "要素に注釈を付ける",
|
||||
placeholder: "この要素についてエージェントへのメッセージ…",
|
||||
submit: "添付",
|
||||
cancel: "キャンセル",
|
||||
intents: {
|
||||
fix: "修正",
|
||||
change: "変更",
|
||||
question: "質問",
|
||||
approve: "承認",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "デバイスサイズ",
|
||||
|
||||
@@ -426,22 +426,17 @@ export const ptBR: TranslationResources = {
|
||||
enterUrl: "Inserir URL",
|
||||
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
|
||||
cancelSelector: "Cancelar seletor de elemento",
|
||||
selectElement: "Selecionar elemento",
|
||||
grabElement: "Copiar elemento para a área de transferência",
|
||||
grabElementLabel: "elemento",
|
||||
grabFailed: "Não foi possível copiar o elemento",
|
||||
annotateElement: "Anotar elemento",
|
||||
screenshotElement: "Capturar elemento",
|
||||
screenshotCopied: "Captura copiada para a área de transferência",
|
||||
elementCopied: "Elemento copiado para a área de transferência",
|
||||
screenshotFailed: "Não foi possível copiar a captura",
|
||||
},
|
||||
annotate: {
|
||||
title: "Enviar feedback ao agente",
|
||||
placeholder: "Descreva o que deve mudar…",
|
||||
title: "Anotar elemento",
|
||||
placeholder: "Mensagem ao agente sobre este elemento…",
|
||||
submit: "Anexar",
|
||||
cancel: "Cancelar",
|
||||
intents: {
|
||||
fix: "Corrigir",
|
||||
change: "Alterar",
|
||||
question: "Pergunta",
|
||||
approve: "Aprovar",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Tamanho do dispositivo",
|
||||
|
||||
@@ -426,22 +426,17 @@ export const ru: TranslationResources = {
|
||||
enterUrl: "Введите URL",
|
||||
openDevTools: "Открыть инструменты разработки браузера",
|
||||
cancelSelector: "Отменить выбор элемента",
|
||||
selectElement: "Выберите элемент",
|
||||
grabElement: "Скопировать элемент в буфер обмена",
|
||||
grabElementLabel: "элемент",
|
||||
grabFailed: "Не удалось скопировать элемент",
|
||||
annotateElement: "Аннотировать элемент",
|
||||
screenshotElement: "Снимок элемента",
|
||||
screenshotCopied: "Снимок скопирован в буфер обмена",
|
||||
elementCopied: "Элемент скопирован в буфер обмена",
|
||||
screenshotFailed: "Не удалось скопировать снимок",
|
||||
},
|
||||
annotate: {
|
||||
title: "Отправить отзыв агенту",
|
||||
placeholder: "Опишите, что нужно изменить…",
|
||||
title: "Аннотировать элемент",
|
||||
placeholder: "Сообщение агенту об этом элементе…",
|
||||
submit: "Прикрепить",
|
||||
cancel: "Отмена",
|
||||
intents: {
|
||||
fix: "Исправить",
|
||||
change: "Изменить",
|
||||
question: "Вопрос",
|
||||
approve: "Одобрить",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Размер устройства",
|
||||
|
||||
@@ -422,22 +422,17 @@ export const zhCN: TranslationResources = {
|
||||
enterUrl: "输入 URL",
|
||||
openDevTools: "打开浏览器开发者工具",
|
||||
cancelSelector: "取消元素选择器",
|
||||
selectElement: "选择元素",
|
||||
grabElement: "复制元素到剪贴板",
|
||||
grabElementLabel: "元素",
|
||||
grabFailed: "复制元素失败",
|
||||
annotateElement: "标注元素",
|
||||
screenshotElement: "截图元素",
|
||||
screenshotCopied: "已将截图复制到剪贴板",
|
||||
elementCopied: "已将元素复制到剪贴板",
|
||||
screenshotFailed: "无法复制截图",
|
||||
},
|
||||
annotate: {
|
||||
title: "发送反馈给智能体",
|
||||
placeholder: "描述需要修改的内容…",
|
||||
title: "标注元素",
|
||||
placeholder: "给智能体关于此元素的留言…",
|
||||
submit: "附加",
|
||||
cancel: "取消",
|
||||
intents: {
|
||||
fix: "修复",
|
||||
change: "修改",
|
||||
question: "提问",
|
||||
approve: "认可",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "设备尺寸",
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -522,10 +523,15 @@ function probeIntervalForConnection(
|
||||
}
|
||||
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
const desktopBrowserAutomationAvailable =
|
||||
const browserHostAvailable =
|
||||
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
|
||||
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
|
||||
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
|
||||
const browserAutomationCapabilities = browserHostAvailable
|
||||
? {
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
||||
hostKind: "desktop app",
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { isNewAgentSchedule } from "@/utils/schedule-format";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
||||
|
||||
export interface ScheduleHostInput {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
@@ -17,65 +18,76 @@ export interface ScheduleRuntime {
|
||||
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
||||
}
|
||||
|
||||
export interface ScheduleHostSection {
|
||||
/** A schedule tagged with the host it came from, so the flat list can render a
|
||||
* per-row host label and scope mutations without host sections. */
|
||||
export interface AggregatedSchedule extends ScheduleSummary {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
schedules: ScheduleSummary[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
export interface ScheduleHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
schedules: AggregatedSchedule[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: readonly ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch schedules across connected hosts and merge them into one flat list.
|
||||
* Connectivity is checked here at execution time (not pre-filtered by the hook)
|
||||
* so the query — retried as the runtime version changes — reliably picks a host
|
||||
* up the moment it comes online, including on a cold deep-link.
|
||||
*
|
||||
* Offline hosts are skipped. A connected host that fails contributes to
|
||||
* `hostErrors` (surfaced as a banner) while the rest still render; only when
|
||||
* every connected host fails do we throw so the screen shows a full error.
|
||||
*/
|
||||
export async function fetchAggregatedSchedules(
|
||||
input: FetchAggregatedSchedulesInput,
|
||||
): Promise<FetchAggregatedSchedulesResult> {
|
||||
const sections = await Promise.all(
|
||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
||||
const schedules: AggregatedSchedule[] = [];
|
||||
const hostErrors: ScheduleHostError[] = [];
|
||||
let connectedAttempts = 0;
|
||||
|
||||
await Promise.all(
|
||||
input.hosts.map(async (host) => {
|
||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = input.runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
connectedAttempts += 1;
|
||||
try {
|
||||
const payload = await client.scheduleList();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: payload.schedules.filter(isNewAgentSchedule),
|
||||
error: null,
|
||||
};
|
||||
for (const schedule of payload.schedules) {
|
||||
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
hostErrors.push({
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
message: toErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { sections };
|
||||
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
|
||||
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
||||
}
|
||||
|
||||
return { schedules, hostErrors };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
|
||||
|
||||
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
|
||||
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
|
||||
return {
|
||||
id: "schedule-1",
|
||||
name: "Nightly",
|
||||
prompt: "Run the task",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
status: "active",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
nextRunAt: "2026-07-02T01:00:00.000Z",
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(
|
||||
schedule: ScheduleSummary,
|
||||
options?: {
|
||||
agents?: Array<[string, ScheduleTargetAgent]>;
|
||||
projects?: Array<[string, string]>;
|
||||
agentDataLoaded?: boolean;
|
||||
},
|
||||
) {
|
||||
return resolveSchedule({
|
||||
schedule,
|
||||
serverId: "host-1",
|
||||
now: NOW,
|
||||
agentsByKey: new Map(options?.agents ?? []),
|
||||
projectNameByCwd: new Map(options?.projects ?? []),
|
||||
agentDataLoaded: options?.agentDataLoaded ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveSchedule state", () => {
|
||||
it("keeps active and paused schedules runnable", () => {
|
||||
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
|
||||
expect(scheduleBucket("active")).toBe("runnable");
|
||||
expect(scheduleBucket("paused")).toBe("runnable");
|
||||
});
|
||||
|
||||
it("treats a past expiresAt as expired regardless of status", () => {
|
||||
const result = resolve(
|
||||
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
|
||||
);
|
||||
expect(result.state).toBe("expired");
|
||||
expect(result.bucket).toBe("ended");
|
||||
});
|
||||
|
||||
it("ignores an unparseable expiresAt", () => {
|
||||
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
|
||||
});
|
||||
|
||||
it("derives finished only from completed-and-not-expired", () => {
|
||||
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
|
||||
expect(
|
||||
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
|
||||
).toBe("expired");
|
||||
});
|
||||
|
||||
it("marks an agent target gone when the client has no such agent", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule).state).toBe("targetGone");
|
||||
expect(resolve(schedule).bucket).toBe("ended");
|
||||
});
|
||||
|
||||
it("does not claim gone before the agent directory has loaded", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
|
||||
});
|
||||
|
||||
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
|
||||
const paused = makeSchedule({
|
||||
status: "paused",
|
||||
target: { type: "agent", agentId: AGENT_ID },
|
||||
});
|
||||
expect(resolve(paused).state).toBe("targetGone");
|
||||
});
|
||||
|
||||
it("never claims a new-agent cwd is gone", () => {
|
||||
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSchedule target line", () => {
|
||||
it("names an agent target by its client title and provider", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
const result = resolve(schedule, {
|
||||
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
|
||||
});
|
||||
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
|
||||
expect(result.state).toBe("active");
|
||||
});
|
||||
|
||||
it("falls back to Untitled agent when the agent has no title", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
const result = resolve(schedule, {
|
||||
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
|
||||
});
|
||||
expect(result.target.label).toBe("Untitled agent");
|
||||
});
|
||||
|
||||
it("labels a gone agent target as unavailable with no glyph", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
|
||||
});
|
||||
|
||||
it("names a new-agent cwd by matched project, else the shortened path", () => {
|
||||
const matched = makeSchedule({
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
});
|
||||
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
|
||||
label: "My Project",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
const unmatched = makeSchedule({
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
|
||||
});
|
||||
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
|
||||
|
||||
// Derived from existing fields only — no new protocol state. "active"/"paused"
|
||||
// mirror the stored status; the rest are computed truths the daemon does not
|
||||
// spell out in a single field.
|
||||
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
|
||||
|
||||
export type ScheduleBucket = "runnable" | "ended";
|
||||
|
||||
export interface ScheduleTargetAgent {
|
||||
title: string | null;
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleTargetResolution {
|
||||
/** The target line: agent title, project name, or the shortened cwd. */
|
||||
label: string;
|
||||
/** Provider glyph for the row, when known. */
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedSchedule {
|
||||
state: ScheduleDerivedState;
|
||||
bucket: ScheduleBucket;
|
||||
target: ScheduleTargetResolution;
|
||||
}
|
||||
|
||||
export interface ResolveScheduleInput {
|
||||
schedule: ScheduleSummary;
|
||||
serverId: string;
|
||||
now: number;
|
||||
/** Client agent directory keyed by `${serverId}:${agentId}`. */
|
||||
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
|
||||
/** Known project roots keyed by `${serverId}:${cwd}`. */
|
||||
projectNameByCwd: ReadonlyMap<string, string>;
|
||||
/**
|
||||
* Whether the agent directory has finished its first load. While false we do
|
||||
* not claim an agent target is gone — absence would just be a cold cache.
|
||||
*/
|
||||
agentDataLoaded: boolean;
|
||||
}
|
||||
|
||||
function agentKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
function isExpired(schedule: ScheduleSummary, now: number): boolean {
|
||||
if (!schedule.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = Date.parse(schedule.expiresAt);
|
||||
return Number.isFinite(expiresAt) && expiresAt <= now;
|
||||
}
|
||||
|
||||
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
|
||||
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
|
||||
if (schedule.target.type !== "agent" || !agentDataLoaded) {
|
||||
return false;
|
||||
}
|
||||
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
|
||||
}
|
||||
|
||||
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
|
||||
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
|
||||
if (schedule.target.type === "agent") {
|
||||
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
|
||||
if (agent) {
|
||||
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
|
||||
}
|
||||
return { label: "Agent unavailable", provider: null };
|
||||
}
|
||||
return {
|
||||
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
|
||||
provider: schedule.target.config.provider,
|
||||
};
|
||||
}
|
||||
|
||||
// One badge, one truth. Order matters: expiry and a missing target are more
|
||||
// informative than the raw "completed"/"paused" status, so they win.
|
||||
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
|
||||
const { schedule, now } = input;
|
||||
if (isExpired(schedule, now)) {
|
||||
return "expired";
|
||||
}
|
||||
if (isAgentTargetGone(input)) {
|
||||
return "targetGone";
|
||||
}
|
||||
if (schedule.status === "completed") {
|
||||
return "finished";
|
||||
}
|
||||
if (schedule.status === "paused") {
|
||||
return "paused";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
|
||||
return state === "active" || state === "paused" ? "runnable" : "ended";
|
||||
}
|
||||
|
||||
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
|
||||
const state = deriveState(input);
|
||||
return {
|
||||
state,
|
||||
bucket: scheduleBucket(state),
|
||||
target: resolveTarget(input),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import {
|
||||
buildProjectNameByCwd,
|
||||
buildScheduleProjectTargets,
|
||||
describeScheduleCwd,
|
||||
} from "./schedule-project-targets";
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
return {
|
||||
projectKey: "proj",
|
||||
projectName: "Project",
|
||||
hosts: [],
|
||||
totalWorkspaceCount: 0,
|
||||
hostCount: 0,
|
||||
onlineHostCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
||||
return {
|
||||
serverId: "host-1",
|
||||
serverName: "Host 1",
|
||||
isOnline: true,
|
||||
repoRoot: "/tmp/project",
|
||||
workspaceCount: 0,
|
||||
workspaces: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildScheduleProjectTargets", () => {
|
||||
it("emits one target per online host with a repo root", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
projectName: "Alpha",
|
||||
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]).toMatchObject({
|
||||
serverId: "host-1",
|
||||
cwd: "/tmp/alpha",
|
||||
projectName: "Alpha",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips offline hosts and blank repo roots", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeScheduleCwd", () => {
|
||||
it("prefers a matched project name and shortens unmatched paths", () => {
|
||||
const byCwd = buildProjectNameByCwd(
|
||||
buildScheduleProjectTargets([
|
||||
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
||||
).toBe("Alpha");
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
||||
).toBe("~/api");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
|
||||
export const PROJECT_OPTION_PREFIX = "project:";
|
||||
|
||||
export interface ScheduleProjectTarget {
|
||||
optionId: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The project roots the schedule form can target: one per online host of each
|
||||
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
|
||||
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
|
||||
*/
|
||||
export function buildScheduleProjectTargets(
|
||||
projects: readonly ProjectSummary[],
|
||||
): ScheduleProjectTarget[] {
|
||||
const targets: ScheduleProjectTarget[] = [];
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
const cwd = host.repoRoot.trim();
|
||||
if (!host.isOnline || !cwd) {
|
||||
continue;
|
||||
}
|
||||
targets.push({
|
||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function projectNameKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd.trim()}`;
|
||||
}
|
||||
|
||||
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
|
||||
export function buildProjectNameByCwd(
|
||||
targets: readonly ScheduleProjectTarget[],
|
||||
): Map<string, string> {
|
||||
const byCwd = new Map<string, string>();
|
||||
for (const target of targets) {
|
||||
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
|
||||
}
|
||||
return byCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name a stored cwd for display: the matching project name when the client
|
||||
* knows this root on this host, otherwise the shortened path itself. Never
|
||||
* blank, never a claim the client cannot back up.
|
||||
*/
|
||||
export function describeScheduleCwd(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
projectNameByCwd: ReadonlyMap<string, string>;
|
||||
}): string {
|
||||
return (
|
||||
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,40 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
||||
import { SchedulesTable } from "@/components/schedules/schedules-table";
|
||||
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
useSchedules,
|
||||
type AggregatedSchedule,
|
||||
type ScheduleHostError,
|
||||
} from "@/hooks/use-schedules";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveSchedule,
|
||||
type ScheduleBucket,
|
||||
type ScheduleTargetAgent,
|
||||
} from "@/schedules/schedule-derivation";
|
||||
import {
|
||||
buildProjectNameByCwd,
|
||||
buildScheduleProjectTargets,
|
||||
} from "@/schedules/schedule-project-targets";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type FormState =
|
||||
@@ -16,6 +42,11 @@ type FormState =
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
||||
|
||||
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
|
||||
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
|
||||
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
|
||||
];
|
||||
|
||||
export function SchedulesScreen(): ReactElement {
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
@@ -27,38 +58,120 @@ export function SchedulesScreen(): ReactElement {
|
||||
}
|
||||
|
||||
function SchedulesScreenContent(): ReactElement {
|
||||
const { sections, isLoading, isError, error, refetch } = useSchedules();
|
||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setForm({ mode: "create" });
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
|
||||
setForm({ mode: "edit", serverId, schedule });
|
||||
}, []);
|
||||
|
||||
const closeForm = useCallback(() => {
|
||||
setForm({ mode: "closed" });
|
||||
}, []);
|
||||
|
||||
const headerAction = useMemo(
|
||||
() => (
|
||||
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
|
||||
New schedule
|
||||
</Button>
|
||||
),
|
||||
[openCreate],
|
||||
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
|
||||
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||
const { projects } = useProjects();
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
|
||||
// Per-host agent-directory readiness from the runtime, not the aggregate agent
|
||||
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
|
||||
// agents, so a still-loading host would falsely mark its agent-target
|
||||
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
|
||||
// host's directory has loaded at least once.
|
||||
const agentDirReadyHosts = useMemo(() => {
|
||||
void runtimeVersion;
|
||||
const ready = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
|
||||
ready.add(host.serverId);
|
||||
}
|
||||
}
|
||||
return ready;
|
||||
}, [hosts, runtime, runtimeVersion]);
|
||||
|
||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedHost !== ALL_HOSTS_OPTION_ID &&
|
||||
!hosts.some((host) => host.serverId === selectedHost)
|
||||
) {
|
||||
setSelectedHost(ALL_HOSTS_OPTION_ID);
|
||||
}
|
||||
}, [hosts, selectedHost]);
|
||||
|
||||
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
|
||||
const openEdit = useCallback((schedule: AggregatedSchedule) => {
|
||||
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
|
||||
}, []);
|
||||
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
|
||||
|
||||
const agentsByKey = useMemo(() => {
|
||||
const map = new Map<string, ScheduleTargetAgent>();
|
||||
for (const agent of agents) {
|
||||
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const projectNameByCwd = useMemo(
|
||||
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
|
||||
[projects],
|
||||
);
|
||||
|
||||
// Resolve every schedule's derived state and target line once, then partition
|
||||
// by the host and status filters. Sorted newest-first for a stable order
|
||||
// across hosts.
|
||||
const resolvedRows = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return schedules.map((schedule) => ({
|
||||
schedule,
|
||||
resolved: resolveSchedule({
|
||||
schedule,
|
||||
serverId: schedule.serverId,
|
||||
now,
|
||||
agentsByKey,
|
||||
projectNameByCwd,
|
||||
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
|
||||
}),
|
||||
}));
|
||||
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
|
||||
|
||||
const visibleRows = useMemo<ScheduleRowView[]>(() => {
|
||||
const singleHost = hosts.length <= 1;
|
||||
return resolvedRows
|
||||
.filter(
|
||||
({ schedule, resolved }) =>
|
||||
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
|
||||
resolved.bucket === statusFilter,
|
||||
)
|
||||
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
|
||||
.map(({ schedule, resolved }) => ({
|
||||
schedule,
|
||||
targetLabel: resolved.target.label,
|
||||
provider: resolved.target.provider,
|
||||
state: resolved.state,
|
||||
serverName: schedule.serverName,
|
||||
singleHost,
|
||||
}));
|
||||
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
|
||||
|
||||
const showLoadError = isError && schedules.length === 0;
|
||||
const showHostFilter = hosts.length > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
||||
<SchedulesBody
|
||||
sections={sections}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
<MenuHeader title="Schedules" />
|
||||
<SchedulesScreenBody
|
||||
rows={visibleRows}
|
||||
hostErrors={hostErrors}
|
||||
hasSchedules={schedules.length > 0}
|
||||
isInitialLoad={isInitialLoad}
|
||||
showLoadError={showLoadError}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
showHostFilter={showHostFilter}
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
onRetry={refetch}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
@@ -74,24 +187,38 @@ function SchedulesScreenContent(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesBody({
|
||||
sections,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
function SchedulesScreenBody({
|
||||
rows,
|
||||
hostErrors,
|
||||
hasSchedules,
|
||||
isInitialLoad,
|
||||
showLoadError,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
showHostFilter,
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
onRetry,
|
||||
onCreate,
|
||||
onEdit,
|
||||
}: {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
rows: ScheduleRowView[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
hasSchedules: boolean;
|
||||
isInitialLoad: boolean;
|
||||
showLoadError: boolean;
|
||||
statusFilter: ScheduleBucket;
|
||||
onStatusFilterChange: (value: ScheduleBucket) => void;
|
||||
showHostFilter: boolean;
|
||||
hosts: ReturnType<typeof useHosts>;
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onRetry: () => void;
|
||||
onCreate: () => void;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
onEdit: (schedule: AggregatedSchedule) => void;
|
||||
}): ReactElement {
|
||||
if (isLoading) {
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||
@@ -99,76 +226,85 @@ function SchedulesBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (showLoadError) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
||||
<Text style={styles.message}>Unable to load schedules</Text>
|
||||
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||
Retry
|
||||
Try again
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (!hasSchedules) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<View style={styles.centered} testID="schedules-empty">
|
||||
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||
<Text style={styles.message}>No schedules yet</Text>
|
||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||
Create a schedule
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
|
||||
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<View style={styles.filterRow}>
|
||||
<View style={styles.filterRowControls}>
|
||||
{showHostFilter ? (
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={onSelectHost}
|
||||
triggerTestID="schedules-host-filter-trigger"
|
||||
/>
|
||||
) : null}
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={statusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
testID="schedules-status-filter"
|
||||
/>
|
||||
</View>
|
||||
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
|
||||
New schedule
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
testID="schedules-sections"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
testID="schedules-list"
|
||||
>
|
||||
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||
{rows.length > 0 ? (
|
||||
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
|
||||
) : (
|
||||
<View style={styles.filterEmpty}>
|
||||
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleHostSectionView({
|
||||
section,
|
||||
onEdit,
|
||||
}: {
|
||||
section: ScheduleHostSection;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
}): ReactElement {
|
||||
const handleEdit = useCallback(
|
||||
(schedule: ScheduleSummary) => {
|
||||
onEdit(section.serverId, schedule);
|
||||
},
|
||||
[onEdit, section.serverId],
|
||||
);
|
||||
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
|
||||
|
||||
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
|
||||
return (
|
||||
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
||||
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
||||
{section.serverName}
|
||||
</Text>
|
||||
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
||||
{section.schedules.length > 0 ? (
|
||||
<SchedulesTable
|
||||
serverId={section.serverId}
|
||||
schedules={section.schedules}
|
||||
onEditSchedule={handleEdit}
|
||||
/>
|
||||
) : null}
|
||||
{section.schedules.length === 0 && !section.error ? (
|
||||
<View style={styles.sectionEmpty}>
|
||||
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.errorsBannerWrap}>
|
||||
<View style={styles.errorsBanner} testID="schedules-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`${error.serverName}: Could not load schedules`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -178,6 +314,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
@@ -185,43 +325,50 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[6],
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
filterRowControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
flexShrink: 1,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
errorsBannerWrap: {
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
},
|
||||
sectionTitle: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
errorsBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionError: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
errorsBannerText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sectionEmpty: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
filterEmpty: {
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
paddingVertical: theme.spacing[6],
|
||||
alignItems: "center",
|
||||
},
|
||||
sectionEmptyText: {
|
||||
filterEmptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
} from "@/components/hosts/host-picker";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { type HostProfile } from "@/types/host-connection";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export function SessionsScreen() {
|
||||
@@ -30,71 +25,6 @@ export function SessionsScreen() {
|
||||
return <SessionsScreenContent />;
|
||||
}
|
||||
|
||||
function SessionsHostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
}: {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={selectedHost}
|
||||
onSelect={onSelectHost}
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="sessions-host-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
||||
>
|
||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
||||
<Server size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<HostStatusDotSlot serverId={selectedHost} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsScreenContent() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
|
||||
<MenuHeader title={t("sessions.title")} />
|
||||
{showHostFilter ? (
|
||||
<View style={styles.filterContainer}>
|
||||
<SessionsHostFilter
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
triggerTestID="sessions-host-filter-trigger"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
filterTriggerWrap: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
export const BROWSER_TOOLS_TITLE = "Browser tools";
|
||||
export const BROWSER_TOOLS_WARNING =
|
||||
"Allow agents to access and control Paseo desktop browser tabs, including logged-in browser state. Only enable this for agents you trust.";
|
||||
"Allow agents to access and control Paseo browser tabs, including logged-in browser state. Only enable this for agents you trust.";
|
||||
|
||||
export interface BrowserToolsCardState {
|
||||
isVisible: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
@@ -22,10 +23,14 @@ interface BrowserStoreState extends BrowserIndexState {
|
||||
}
|
||||
|
||||
function createBrowserId(): string {
|
||||
let browserId: string;
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
browserId = globalThis.crypto.randomUUID();
|
||||
} else {
|
||||
const randomSuffix = Math.random().toString(16).slice(2) || "0";
|
||||
browserId = `${Date.now()}-${randomSuffix}`;
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return BrowserAutomationBrowserIdSchema.parse(browserId);
|
||||
}
|
||||
|
||||
export const useBrowserStore = create<BrowserStoreState>()(
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
|
||||
it("renders a single-workspace git project as an expandable section with the new workspace action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
@@ -79,13 +79,49 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the project host, not route state, for new worktree actions", () => {
|
||||
it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "directory", workspaces: [] }),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["srv", true]]),
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "directory", workspaces: [] }),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["srv", false]]),
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("still shows the new workspace action for a git project regardless of multiplicity", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "git" }),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["srv", false]]),
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the project host, not route state, for new workspace actions", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
hosts: [
|
||||
@@ -98,13 +134,34 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
|
||||
it("targets the first multiplicity-capable host for a non-git project", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "directory",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
|
||||
],
|
||||
}),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["host-b", true]]),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
trailingAction: {
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a multi-workspace git project as an expandable section with a new workspace action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
@@ -120,7 +177,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
@@ -149,7 +206,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
kind: "project_section",
|
||||
chevron: "collapse",
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget {
|
||||
}
|
||||
|
||||
export type SidebarProjectTrailingAction =
|
||||
| { kind: "new_worktree"; target: SidebarProjectHostTarget }
|
||||
| { kind: "new_workspace"; target: SidebarProjectHostTarget }
|
||||
| { kind: "none" };
|
||||
|
||||
export interface SidebarProjectSectionRowModel {
|
||||
@@ -17,6 +17,8 @@ export interface SidebarProjectSectionRowModel {
|
||||
|
||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||
|
||||
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
||||
|
||||
function hostTarget(input: {
|
||||
serverId: string;
|
||||
iconWorkingDir: string;
|
||||
@@ -40,9 +42,18 @@ export function resolveSidebarProjectIconTarget(
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null {
|
||||
// A project can host a brand-new workspace on a host when that host can create a
|
||||
// git worktree (git projects) OR the host supports running multiple independent
|
||||
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
|
||||
// directories add a second workspace. Mirrors the gate used by the global "New
|
||||
// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's
|
||||
// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`.
|
||||
function resolveNewWorkspaceTarget(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
): SidebarProjectHostTarget | null {
|
||||
for (const host of project.hosts) {
|
||||
if (!host.canCreateWorktree) {
|
||||
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
|
||||
continue;
|
||||
}
|
||||
const target = hostTarget(host);
|
||||
@@ -53,18 +64,25 @@ function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectH
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction {
|
||||
const target = resolveNewWorktreeTarget(project);
|
||||
return target ? { kind: "new_worktree", target } : { kind: "none" };
|
||||
function projectTrailingAction(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
): SidebarProjectTrailingAction {
|
||||
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
|
||||
return target ? { kind: "new_workspace", target } : { kind: "none" };
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
|
||||
}): SidebarProjectRowModel {
|
||||
return {
|
||||
kind: "project_section",
|
||||
chevron: input.collapsed ? "expand" : "collapse",
|
||||
trailingAction: projectTrailingAction(input.project),
|
||||
trailingAction: projectTrailingAction(
|
||||
input.project,
|
||||
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.103",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
"@getpaseo/server": "0.1.103",
|
||||
"@getpaseo/client": "0.1.104-beta.4",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/server": "0.1.104-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
executablePath?: string;
|
||||
desktopManaged?: boolean;
|
||||
}
|
||||
|
||||
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
||||
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
||||
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
||||
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -33,9 +33,10 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
try {
|
||||
const serverInfo = await client.waitForServerInfo(PAIRING_DAEMON_RPC_TIMEOUT_MS);
|
||||
if (serverInfo.features?.daemonStatusRpc === true) {
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
const offer = await client.getDaemonPairingOffer({
|
||||
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -45,11 +46,11 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
options,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from "commander";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
|
||||
findExecutable: vi.fn(async () => null),
|
||||
getOrCreateServerId: vi.fn(() => "srv_test"),
|
||||
loadConfig: vi.fn(() => ({
|
||||
listen: "127.0.0.1:6767",
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: false,
|
||||
relayPublicUseTls: false,
|
||||
})),
|
||||
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
execCommand: mocks.execCommand,
|
||||
findExecutable: mocks.findExecutable,
|
||||
getOrCreateServerId: mocks.getOrCreateServerId,
|
||||
loadConfig: mocks.loadConfig,
|
||||
resolvePaseoHome: mocks.resolvePaseoHome,
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
}));
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
|
||||
tempRoots.push(root);
|
||||
const home = path.join(root, ".paseo");
|
||||
await mkdir(home, { recursive: true });
|
||||
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
|
||||
return home;
|
||||
}
|
||||
|
||||
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
process.env.PATH = "";
|
||||
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
|
||||
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("daemon status desktop management fields", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("reports executable identity without deriving desktop management in the CLI", async () => {
|
||||
const executablePath =
|
||||
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
|
||||
const home = await createStatusHome({
|
||||
pid: process.pid,
|
||||
startedAt: "2026-07-06T00:00:00.000Z",
|
||||
hostname: "dev-host",
|
||||
uid: 501,
|
||||
listen: "/tmp/paseo-status.sock",
|
||||
executablePath,
|
||||
});
|
||||
|
||||
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||
daemonExecutablePath: executablePath,
|
||||
desktopManaged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
|
||||
const home = await createStatusHome({
|
||||
pid: process.pid,
|
||||
startedAt: "2026-07-06T00:00:00.000Z",
|
||||
hostname: "dev-host",
|
||||
uid: 501,
|
||||
listen: "/tmp/paseo-status.sock",
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||
daemonExecutablePath: null,
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -29,6 +28,7 @@ interface DaemonStatus {
|
||||
owner: string | null;
|
||||
logPath: string;
|
||||
daemonNode: string;
|
||||
daemonExecutablePath: string | null;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
daemonVersion: string | null;
|
||||
@@ -127,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
{ key: "Owner", value: status.owner ?? "-" },
|
||||
{ key: "Logs", value: status.logPath },
|
||||
{ key: "Daemon Node", value: status.daemonNode },
|
||||
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
|
||||
{ key: "CLI Node", value: status.cliNode },
|
||||
{ key: "CLI", value: status.cliVersion },
|
||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||
@@ -157,9 +158,11 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = AGENT_PROVIDER_DEFINITIONS.filter(
|
||||
(provider) => provider.voice?.enabled === true && provider.defaultModeId !== null,
|
||||
).map((provider) => ({ label: provider.label, binary: provider.id }));
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
||||
{ label: "Claude", binary: "claude" },
|
||||
{ label: "Codex", binary: "codex" },
|
||||
{ label: "OpenCode", binary: "opencode" },
|
||||
];
|
||||
|
||||
async function checkProviderBinary(
|
||||
binary: string,
|
||||
@@ -404,9 +407,11 @@ export async function runStatusCommand(
|
||||
owner,
|
||||
logPath: state.logPath,
|
||||
daemonNode,
|
||||
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
|
||||
cliNode,
|
||||
cliVersion,
|
||||
daemonVersion,
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||
providers,
|
||||
note,
|
||||
|
||||
@@ -50,7 +50,10 @@ export function addLoopRunOptions(command: Command): Command {
|
||||
.argument("<prompt>", "Prompt for each fresh worker iteration")
|
||||
.option("--provider <provider>", "Default provider for worker and verifier agents")
|
||||
.option("--model <model>", "Default model for worker and verifier agents")
|
||||
.option("--mode <mode>", "Provider-specific mode for the worker agent")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--verify-provider <provider>", "Provider for the verifier agent")
|
||||
.option("--verify-model <model>", "Model for the verifier agent")
|
||||
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runAddCommand } from "./add.js";
|
||||
|
||||
interface CatalogEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
api: string;
|
||||
baseUrl: string;
|
||||
auth: Record<string, unknown>;
|
||||
models: Array<{ id: string; label?: string; reasoning?: boolean }>;
|
||||
}
|
||||
|
||||
interface RecordingClientInput {
|
||||
catalog: CatalogEntry[];
|
||||
features?: Record<string, unknown>;
|
||||
setProvider?: (input: {
|
||||
name: string;
|
||||
providerType: string;
|
||||
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||
}) => Promise<unknown>;
|
||||
startOAuth?: (
|
||||
name: string,
|
||||
options?: { mode?: string; requestId?: string } | string,
|
||||
) => Promise<unknown>;
|
||||
completeOAuth?: (name: string) => Promise<unknown>;
|
||||
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function createClient(input: RecordingClientInput) {
|
||||
return {
|
||||
waitForServerInfo: async () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: input.features ?? { paseoAgentCatalog: true },
|
||||
}),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: input.catalog,
|
||||
error: null,
|
||||
}),
|
||||
setPaseoAgentProvider: async (providerInput: {
|
||||
name: string;
|
||||
providerType: string;
|
||||
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||
}) => {
|
||||
if (input.setProvider) {
|
||||
return input.setProvider(providerInput);
|
||||
}
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: providerInput.name,
|
||||
providerType: providerInput.providerType,
|
||||
models: providerInput.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
startPaseoAgentOAuth: async (
|
||||
name: string,
|
||||
options?: { mode?: string; requestId?: string } | string,
|
||||
) =>
|
||||
input.startOAuth?.(name, options) ?? {
|
||||
requestId: "oauth-start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
completePaseoAgentOAuth: async (name: string) =>
|
||||
input.completeOAuth?.(name) ?? {
|
||||
requestId: "oauth-complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
storePaseoAgentOAuthCredential: async (credentialInput: {
|
||||
name: string;
|
||||
credential: unknown;
|
||||
}) =>
|
||||
input.storeCredential?.(credentialInput) ?? {
|
||||
requestId: "oauth-store-1",
|
||||
success: true,
|
||||
name: credentialInput.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
api: "test-api",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
hint: "Create an Alpha key before continuing.",
|
||||
keyUrl: "https://alpha.example.test/keys",
|
||||
placeholder: "Alpha API key",
|
||||
},
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function oauthEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
api: "test-oauth-api",
|
||||
baseUrl: "https://beta.example.test",
|
||||
auth: { kind: "oauth", flow: "beta-flow" },
|
||||
models: [{ id: "beta-model" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider add", () => {
|
||||
it("configures an API-key provider from a hidden prompt without echoing the key", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const prompts: string[] = [];
|
||||
const output: string[] = [];
|
||||
const result = await runAddCommand(
|
||||
"alpha-key",
|
||||
{ host: "localhost:7777", name: "alpha-main" },
|
||||
{} as never,
|
||||
{
|
||||
write: (message) => output.push(message),
|
||||
promptSecret: async (message) => {
|
||||
prompts.push(message);
|
||||
return "redaction-sentinel";
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "redaction-sentinel",
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(prompts).toEqual(["Enter Alpha API key (leave empty to use $ALPHA_API_KEY):"]);
|
||||
expect(output.join("\n")).toContain("Create an Alpha key");
|
||||
expect(output.join("\n")).toContain("https://alpha.example.test/keys");
|
||||
expect(render(result, { format: "json" })).not.toContain("redaction-sentinel");
|
||||
expect(render(result, { format: "table", noColor: true })).toContain("alpha-main");
|
||||
});
|
||||
|
||||
it("stores an environment reference when the API-key prompt is empty", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const result = await runAddCommand("alpha-key", {}, {} as never, {
|
||||
promptSecret: async () => "",
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: false, source: "env" },
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.data.auth).toBe("Needs attention");
|
||||
});
|
||||
|
||||
it("reads an API key from stdin for scripts", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
|
||||
await runAddCommand("alpha-key", { apiKeyStdin: true, model: ["one,two"] }, {} as never, {
|
||||
readStdin: async () => "stdin-secret\n",
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not be used with --api-key-stdin");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ models: [] })],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "stdin-secret",
|
||||
models: [{ id: "one" }, { id: "two" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("configures an API-key provider without model defaults", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
|
||||
const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, {
|
||||
readStdin: async () => "stdin-secret\n",
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not be used with --api-key-stdin");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ models: [] })],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "stdin-secret",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.data).toMatchObject({
|
||||
name: "alpha-key",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
|
||||
const order: string[] = [];
|
||||
const stored: unknown[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
|
||||
const result = await runAddCommand(
|
||||
"beta-oauth",
|
||||
{ host: "tcp://remote:7777?ssl=true&password=secret", name: "beta-main" },
|
||||
{} as never,
|
||||
{
|
||||
write: (message) => order.push(`write:${message}`),
|
||||
openBrowser: (url) => {
|
||||
openedUrls.push(url);
|
||||
return true;
|
||||
},
|
||||
loginBrowserCredential: async (options) => {
|
||||
expect(options.flow).toBe("beta-flow");
|
||||
order.push("browser-login");
|
||||
options.onAuthUrl("https://auth.example.test/browser", "Authorize in the browser.");
|
||||
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("manual prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
setProvider: async (input) => {
|
||||
order.push("set-provider");
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "oauth", configured: false },
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
storeCredential: async (input) => {
|
||||
stored.push(input);
|
||||
return {
|
||||
requestId: "store-1",
|
||||
success: true,
|
||||
name: input.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual([
|
||||
"set-provider",
|
||||
"browser-login",
|
||||
"write:Authorize in the browser.",
|
||||
"write: https://auth.example.test/browser",
|
||||
"write:Waiting for you to approve in the browser...",
|
||||
"write:Credential accepted by selected daemon (tcp://remote:7777?ssl=true).",
|
||||
]);
|
||||
expect(stored).toEqual([
|
||||
{
|
||||
name: "beta-main",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(openedUrls).toEqual(["https://auth.example.test/browser"]);
|
||||
expect(result.data.auth).toBe("Connected");
|
||||
expect(order.join("\n")).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("uses daemon-run OAuth when --device-code is passed", async () => {
|
||||
const output: string[] = [];
|
||||
const oauthCalls: string[] = [];
|
||||
|
||||
const result = await runAddCommand("beta-oauth", { deviceCode: true }, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
openBrowser: () => {
|
||||
throw new Error("browser should not be opened for --device-code");
|
||||
},
|
||||
loginBrowserCredential: async () => {
|
||||
throw new Error("browser login should not run for --device-code");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
startOAuth: async (name, options) => {
|
||||
oauthCalls.push(
|
||||
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
|
||||
);
|
||||
return {
|
||||
requestId: "start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
completeOAuth: async (name) => {
|
||||
oauthCalls.push(`complete:${name}`);
|
||||
return {
|
||||
requestId: "complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
|
||||
expect(output.join("\n")).toContain("ABCD-EFGH");
|
||||
expect(output.join("\n")).toContain("https://auth.example.test/device");
|
||||
expect(result.data.auth).toBe("Connected");
|
||||
});
|
||||
|
||||
it("falls back to daemon-run OAuth when the browser cannot open", async () => {
|
||||
const output: string[] = [];
|
||||
const oauthCalls: string[] = [];
|
||||
|
||||
await runAddCommand("beta-oauth", {}, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
openBrowser: () => false,
|
||||
loginBrowserCredential: async (options) => {
|
||||
options.onAuthUrl("https://auth.example.test/browser");
|
||||
throw new Error("onAuthUrl should have switched to device-code");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
startOAuth: async (name, options) => {
|
||||
oauthCalls.push(
|
||||
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
|
||||
);
|
||||
return {
|
||||
requestId: "start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "WXYZ-1234",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
completeOAuth: async (name) => {
|
||||
oauthCalls.push(`complete:${name}`);
|
||||
return {
|
||||
requestId: "complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
|
||||
expect(output.join("\n")).toContain("Browser could not be opened");
|
||||
expect(output.join("\n")).toContain("WXYZ-1234");
|
||||
});
|
||||
|
||||
it("updates the same instance on repeated add calls", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const client = createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const dependencies = {
|
||||
promptSecret: async () => "secret",
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () => client,
|
||||
};
|
||||
|
||||
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
|
||||
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
|
||||
|
||||
expect(setCalls).toHaveLength(2);
|
||||
expect(setCalls).toEqual([
|
||||
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
|
||||
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("mentions known provider ids for an unknown catalog id", async () => {
|
||||
await expect(
|
||||
runAddCommand("missing-key", {}, {} as never, {
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not run");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not run");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })],
|
||||
setProvider: async () => {
|
||||
throw new Error("set should not run");
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNKNOWN_PROVIDER",
|
||||
message:
|
||||
'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.',
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the catalog feature flag before reading the catalog", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
runAddCommand("alpha-key", {}, {} as never, {
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not run");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not run");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () => ({
|
||||
waitForServerInfo: async () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: {},
|
||||
}),
|
||||
getPaseoAgentCatalog: async () => {
|
||||
calls.push("catalog");
|
||||
throw new Error("catalog should not be read");
|
||||
},
|
||||
setPaseoAgentProvider: async () => {
|
||||
calls.push("set");
|
||||
throw new Error("set should not run");
|
||||
},
|
||||
startPaseoAgentOAuth: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
completePaseoAgentOAuth: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
storePaseoAgentOAuthCredential: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets the user choose a provider when no id is passed", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
await runAddCommand(undefined, {}, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
promptText: async (message) => {
|
||||
expect(message).toBe("Select provider:");
|
||||
return "2";
|
||||
},
|
||||
promptSecret: async () => "chosen-secret",
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [
|
||||
apiKeyEntry({ id: "first-key", label: "First Key" }),
|
||||
apiKeyEntry({ id: "second-key", label: "Second Key" }),
|
||||
],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(output.join("\n")).toContain("First Key (first-key)");
|
||||
expect(output.join("\n")).toContain("Second Key (second-key)");
|
||||
expect(setCalls).toEqual([
|
||||
expect.objectContaining({ name: "second-key", providerType: "second-key" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentProviderAuthState,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { loginOAuthBrowser } from "@getpaseo/server";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { openBrowserUrl } from "../../utils/open-browser.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface ProviderAddOptions extends CommandOptions {
|
||||
name?: string;
|
||||
apiKeyStdin?: boolean;
|
||||
deviceCode?: boolean;
|
||||
model?: string[];
|
||||
}
|
||||
|
||||
interface ProviderModelInput {
|
||||
id: string;
|
||||
label?: string;
|
||||
api?: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
interface ProviderConfiguredItem {
|
||||
name: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface ProviderAddClient extends Pick<
|
||||
DaemonClient,
|
||||
| "waitForServerInfo"
|
||||
| "getPaseoAgentCatalog"
|
||||
| "setPaseoAgentProvider"
|
||||
| "startPaseoAgentOAuth"
|
||||
| "completePaseoAgentOAuth"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
| "close"
|
||||
> {}
|
||||
|
||||
export interface ProviderAddDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderAddClient>;
|
||||
readStdin: () => Promise<string>;
|
||||
promptText: (message: string) => Promise<string>;
|
||||
promptSecret: (message: string) => Promise<string>;
|
||||
loginBrowserCredential: typeof loginOAuthBrowser;
|
||||
openBrowser: (url: string) => boolean;
|
||||
write: (message: string) => void;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderAddDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
readStdin,
|
||||
promptText,
|
||||
promptSecret,
|
||||
loginBrowserCredential: loginOAuthBrowser,
|
||||
openBrowser: openBrowserUrl,
|
||||
write: (message) => console.error(message),
|
||||
};
|
||||
|
||||
export const providerConfiguredSchema: OutputSchema<ProviderConfiguredItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 16 },
|
||||
{ header: "LABEL", field: "label", width: 22 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
process.stdin.setEncoding("utf8");
|
||||
let value = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
value += chunk;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function promptText(message: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
return (await rl.question(`${message} `)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
const text = String(chunk);
|
||||
if (text.includes(message)) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
}) as Writable & { isTTY?: boolean };
|
||||
output.isTTY = true;
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
||||
try {
|
||||
return (await rl.question(`${message} `)).trim();
|
||||
} finally {
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
|
||||
const auth = entry.auth;
|
||||
const value = auth[field];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function requireAuthField(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
field: string,
|
||||
description: string,
|
||||
): string {
|
||||
const value = authField(entry, field);
|
||||
if (value) return value;
|
||||
|
||||
throw {
|
||||
code: "UNSUPPORTED_PROVIDER_AUTH",
|
||||
message: `Provider ${entry.id} is missing ${description}. Update the Paseo daemon to use this command.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
|
||||
return requireAuthField(entry, "envVar", "an API key environment variable");
|
||||
}
|
||||
|
||||
function oauthFlow(entry: PaseoAgentCatalogEntry): string {
|
||||
return requireAuthField(entry, "flow", "an OAuth flow");
|
||||
}
|
||||
|
||||
function normalizeModels(rawModels: string[] | undefined): string[] {
|
||||
return (rawModels ?? [])
|
||||
.flatMap((value) => value.split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
|
||||
return entry.models.map((model) => ({
|
||||
id: model.id,
|
||||
...(model.label ? { label: model.label } : {}),
|
||||
...(model.api ? { api: model.api } : {}),
|
||||
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
||||
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
|
||||
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveModels(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
options: ProviderAddOptions,
|
||||
): ProviderModelInput[] | undefined {
|
||||
const modelIds = normalizeModels(options.model);
|
||||
if (modelIds.length > 0) {
|
||||
return modelIds.map((id) => ({ id }));
|
||||
}
|
||||
|
||||
const models = catalogModels(entry);
|
||||
if (models.length > 0) {
|
||||
return models;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function selectCatalogEntry(
|
||||
catalog: PaseoAgentCatalogEntry[],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentCatalogEntry> {
|
||||
if (catalog.length === 0) {
|
||||
throw {
|
||||
code: "EMPTY_PROVIDER_CATALOG",
|
||||
message: "The Paseo daemon returned an empty provider catalog.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
dependencies.write("Available model providers:");
|
||||
catalog.forEach((entry, index) => {
|
||||
dependencies.write(` ${index + 1}. ${entry.label} (${entry.id})`);
|
||||
});
|
||||
const answer = await dependencies.promptText("Select provider:");
|
||||
const selectedIndex = Number(answer);
|
||||
const byIndex = Number.isInteger(selectedIndex) ? catalog[selectedIndex - 1] : undefined;
|
||||
const byId = catalog.find((entry) => entry.id === answer);
|
||||
const selected = byIndex ?? byId;
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
throw {
|
||||
code: "INVALID_PROVIDER_SELECTION",
|
||||
message: `Invalid provider selection: ${answer}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
async function resolveEntry(
|
||||
id: string | undefined,
|
||||
catalog: PaseoAgentCatalogEntry[],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentCatalogEntry> {
|
||||
if (!id) {
|
||||
return selectCatalogEntry(catalog, dependencies);
|
||||
}
|
||||
|
||||
const entry = catalog.find((candidate) => candidate.id === id);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
|
||||
throw {
|
||||
code: "UNKNOWN_PROVIDER",
|
||||
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function formatAuthState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||
if (!provider.auth) {
|
||||
return "not configured";
|
||||
}
|
||||
return provider.auth.configured ? "Connected" : "Needs attention";
|
||||
}
|
||||
|
||||
function toConfiguredItem(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): ProviderConfiguredItem {
|
||||
return {
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
label: entry.label,
|
||||
auth: formatAuthState(provider),
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", ") || "-",
|
||||
};
|
||||
}
|
||||
|
||||
function formatDaemonTarget(host: string | undefined): string {
|
||||
if (!host) {
|
||||
return "local daemon";
|
||||
}
|
||||
try {
|
||||
if (host.startsWith("tcp://")) {
|
||||
const url = new URL(host);
|
||||
url.searchParams.delete("password");
|
||||
return `selected daemon (${url.toString()})`;
|
||||
}
|
||||
} catch {
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
|
||||
async function resolveApiKey(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<string> {
|
||||
const envVar = apiKeyEnvVar(entry);
|
||||
if (options.apiKeyStdin) {
|
||||
const value = (await dependencies.readStdin()).trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
throw {
|
||||
code: "MISSING_API_KEY",
|
||||
message: "No API key was read from stdin.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const hint = authField(entry, "hint");
|
||||
const keyUrl = authField(entry, "keyUrl");
|
||||
if (hint) {
|
||||
dependencies.write(hint);
|
||||
}
|
||||
if (keyUrl) {
|
||||
dependencies.write(`API key URL: ${keyUrl}`);
|
||||
}
|
||||
const placeholder = authField(entry, "placeholder") ?? "API key";
|
||||
const value = await dependencies.promptSecret(
|
||||
`Enter ${placeholder} (leave empty to use $${envVar}):`,
|
||||
);
|
||||
return value || `$${envVar}`;
|
||||
}
|
||||
|
||||
function isBrowserOpenError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as CommandError).code === "BROWSER_OPEN_FAILED"
|
||||
);
|
||||
}
|
||||
|
||||
function printOAuthAuthorization(
|
||||
authorization: Awaited<ReturnType<ProviderAddClient["startPaseoAgentOAuth"]>>["authorization"],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): void {
|
||||
if (!authorization) {
|
||||
dependencies.write("Authorization completed; waiting for the daemon to store credentials...");
|
||||
return;
|
||||
}
|
||||
if (authorization.instructions) {
|
||||
dependencies.write(authorization.instructions);
|
||||
}
|
||||
if (authorization.verificationUri) {
|
||||
dependencies.write(`Open: ${authorization.verificationUri}`);
|
||||
}
|
||||
if (authorization.userCode) {
|
||||
dependencies.write(`Code: ${authorization.userCode}`);
|
||||
}
|
||||
if (authorization.url) {
|
||||
dependencies.write(`Open: ${authorization.url}`);
|
||||
}
|
||||
if (authorization.expiresInSeconds) {
|
||||
dependencies.write(
|
||||
`Expires in about ${Math.round(authorization.expiresInSeconds / 60)} minutes.`,
|
||||
);
|
||||
}
|
||||
dependencies.write("Waiting for authorization...");
|
||||
}
|
||||
|
||||
async function runDaemonOAuth(
|
||||
client: ProviderAddClient,
|
||||
name: string,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
const started = await client.startPaseoAgentOAuth(name, { mode: "device_code" });
|
||||
if (!started.success) {
|
||||
throw {
|
||||
code: "OAUTH_START_FAILED",
|
||||
message: started.error ?? "Daemon rejected the OAuth start request.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
printOAuthAuthorization(started.authorization, dependencies);
|
||||
const completed = await client.completePaseoAgentOAuth(name);
|
||||
if (!completed.success) {
|
||||
throw {
|
||||
code: "OAUTH_COMPLETE_FAILED",
|
||||
message: completed.error ?? "Daemon did not complete OAuth.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return completed.auth;
|
||||
}
|
||||
|
||||
async function runBrowserOAuth(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
const target = formatDaemonTarget(options.host);
|
||||
const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({
|
||||
flow: oauthFlow(entry),
|
||||
onAuthUrl: (url, instructions) => {
|
||||
const opened = dependencies.openBrowser(url);
|
||||
if (!opened) {
|
||||
throw {
|
||||
code: "BROWSER_OPEN_FAILED",
|
||||
message: "Browser could not be opened.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
dependencies.write(instructions ?? "Opening your browser to authorize Paseo.");
|
||||
dependencies.write(` ${url}`);
|
||||
dependencies.write("Waiting for you to approve in the browser...");
|
||||
},
|
||||
onProgress: (message) => dependencies.write(message),
|
||||
promptForCode: dependencies.promptText,
|
||||
});
|
||||
const result = await client.storePaseoAgentOAuthCredential({ name, credential });
|
||||
if (!result.success) {
|
||||
throw {
|
||||
code: "OAUTH_STORE_FAILED",
|
||||
message: result.error ?? "Daemon rejected the OAuth credential.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
dependencies.write(`Credential accepted by ${target}.`);
|
||||
return result.auth;
|
||||
}
|
||||
|
||||
async function configureProvider(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<RedactedPaseoAgentProviderConfig> {
|
||||
const models = resolveModels(entry, options);
|
||||
const apiKey =
|
||||
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
|
||||
const result = await client.setPaseoAgentProvider({
|
||||
name,
|
||||
providerType: entry.id,
|
||||
options: {
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(models ? { models } : {}),
|
||||
},
|
||||
});
|
||||
if (!result.success || !result.provider) {
|
||||
throw {
|
||||
code: "PROVIDER_CONFIG_FAILED",
|
||||
message: result.error ?? "Daemon rejected the provider config.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return result.provider;
|
||||
}
|
||||
|
||||
async function authenticateOAuthProvider(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
if (options.deviceCode) {
|
||||
return runDaemonOAuth(client, name, dependencies);
|
||||
}
|
||||
try {
|
||||
return await runBrowserOAuth(client, entry, name, options, dependencies);
|
||||
} catch (error) {
|
||||
if (!isBrowserOpenError(error)) {
|
||||
throw error;
|
||||
}
|
||||
dependencies.write("Browser could not be opened; using device-code authorization.");
|
||||
return runDaemonOAuth(client, name, dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAddCommand(
|
||||
id: string | undefined,
|
||||
options: ProviderAddOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderAddDependencies> = {},
|
||||
): Promise<SingleResult<ProviderConfiguredItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const catalogResult = await client.getPaseoAgentCatalog();
|
||||
if (catalogResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_CATALOG_FAILED",
|
||||
message: catalogResult.error,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const entry = await resolveEntry(id, catalogResult.catalog, deps);
|
||||
const name = options.name?.trim() || entry.id;
|
||||
const provider = await configureProvider(client, entry, name, options, deps);
|
||||
if (entry.auth.kind !== "api_key" && entry.auth.kind !== "oauth") {
|
||||
throw {
|
||||
code: "UNSUPPORTED_PROVIDER_AUTH",
|
||||
message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const auth =
|
||||
entry.auth.kind === "oauth"
|
||||
? await authenticateOAuthProvider(client, entry, name, options, deps)
|
||||
: provider.auth;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: toConfiguredItem(auth ? { ...provider, auth } : provider, entry),
|
||||
schema: providerConfiguredSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function addProviderAddOptions(command: Command): Command {
|
||||
return command
|
||||
.description("Configure a Paseo Agent model provider")
|
||||
.argument("[id]", "Catalog provider id; omit to choose interactively")
|
||||
.option("--name <instanceName>", "Provider instance name (default: provider id)")
|
||||
.option(
|
||||
"--model <id>",
|
||||
"Model ID to expose (repeatable, comma-separated; defaults to catalog models)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--device-code", "Use daemon-run device-code OAuth instead of browser OAuth");
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
function createServerInfo(features: ServerInfoStatusPayload["features"]): ServerInfoStatusPayload {
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider feature gate", () => {
|
||||
it("waits for delayed server_info before allowing catalog commands", async () => {
|
||||
let resolveServerInfo: ((serverInfo: ServerInfoStatusPayload) => void) | null = null;
|
||||
|
||||
const allowed = requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () =>
|
||||
new Promise<ServerInfoStatusPayload>((resolve) => {
|
||||
resolveServerInfo = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveServerInfo).not.toBeNull();
|
||||
resolveServerInfo?.(createServerInfo({ paseoAgentCatalog: true }));
|
||||
|
||||
await expect(allowed).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces the server_info wait timeout", async () => {
|
||||
const timeoutError = new Error("Timed out waiting for server_info status message (5ms)");
|
||||
|
||||
await expect(
|
||||
requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () => {
|
||||
throw timeoutError;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Timed out waiting for server_info status message (5ms)");
|
||||
});
|
||||
|
||||
it("requires the catalog feature when server_info arrives without it", async () => {
|
||||
await expect(
|
||||
requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () => createServerInfo({ paseoAgentCatalog: false }),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CommandError } from "../../output/index.js";
|
||||
|
||||
export interface PaseoAgentCatalogFeatureClient extends Pick<DaemonClient, "waitForServerInfo"> {}
|
||||
|
||||
export async function requirePaseoAgentCatalogFeature(
|
||||
client: PaseoAgentCatalogFeatureClient,
|
||||
): Promise<void> {
|
||||
const serverInfo = await client.waitForServerInfo();
|
||||
if (serverInfo.features?.paseoAgentCatalog === true) {
|
||||
return;
|
||||
}
|
||||
throw {
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
@@ -1,53 +1,23 @@
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runModelsCommand } from "./models.js";
|
||||
import { addProviderAddOptions, runAddCommand, type ProviderAddDependencies } from "./add.js";
|
||||
import { runRmCommand, type ProviderRmDependencies } from "./rm.js";
|
||||
import type { ProviderListItem, ProviderLsDependencies } from "./ls.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createProviderCommand(
|
||||
dependencies: Partial<
|
||||
ProviderAddDependencies & ProviderLsDependencies & ProviderRmDependencies
|
||||
> = {},
|
||||
): Command {
|
||||
const provider = new Command("provider").description(
|
||||
"Manage Paseo Agent model providers and agent provider models",
|
||||
);
|
||||
export function createProviderCommand(): Command {
|
||||
const provider = new Command("provider").description("Manage agent providers");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider.command("ls").description("List configured Paseo Agent model providers"),
|
||||
).action(
|
||||
withOutput<ProviderListItem, []>((options, command) =>
|
||||
runLsCommand(options, command, dependencies),
|
||||
),
|
||||
);
|
||||
provider.command("ls").description("List available providers and status"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("models")
|
||||
.description("List models for a provider")
|
||||
.argument("<provider>", "Provider name")
|
||||
.argument("<provider>", "Provider name (claude, codex, opencode)")
|
||||
.option("--thinking", "Include thinking option IDs for each model"),
|
||||
).action(withOutput(runModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action(
|
||||
withOutput<Awaited<ReturnType<typeof runAddCommand>>["data"], [string | undefined]>(
|
||||
(id, options, command) => runAddCommand(id, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("rm")
|
||||
.description("Remove a Paseo Agent model provider")
|
||||
.argument("<name>", "Provider instance name"),
|
||||
).action(
|
||||
withOutput<Awaited<ReturnType<typeof runRmCommand>>["data"], [string]>(
|
||||
(name, options, command) => runRmCommand(name, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runRmCommand } from "./rm.js";
|
||||
|
||||
function createServerInfo() {
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentCatalog: true },
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider ls", () => {
|
||||
it("renders an empty configured-provider table with headers", async () => {
|
||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async () => ({
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: [],
|
||||
error: null,
|
||||
}),
|
||||
getPaseoAgentProviders: async () => ({
|
||||
requestId: "providers-1",
|
||||
defaultModel: null,
|
||||
providers: [],
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(render(result, { format: "table", noColor: true })).toContain("NAME");
|
||||
expect(render(result, { format: "json" })).toBe("[]");
|
||||
});
|
||||
|
||||
it("lists configured model providers with catalog labels and auth states", async () => {
|
||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: [
|
||||
{
|
||||
id: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
api: "test-api",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-model" }],
|
||||
},
|
||||
{
|
||||
id: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
api: "test-oauth-api",
|
||||
baseUrl: "https://beta.example.test",
|
||||
auth: { kind: "oauth", flow: "beta-flow" },
|
||||
models: [{ id: "beta-model" }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
getPaseoAgentProviders: async () => ({
|
||||
requestId: "providers-1",
|
||||
defaultModel: null,
|
||||
providers: [
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
models: [{ id: "alpha-model" }],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "beta-main",
|
||||
providerType: "beta-oauth",
|
||||
models: [{ id: "beta-model" }],
|
||||
auth: { kind: "oauth", configured: false, hint: "sign in again" },
|
||||
available: false,
|
||||
error: "auth missing",
|
||||
},
|
||||
{
|
||||
name: "manual-main",
|
||||
providerType: "manual-type",
|
||||
models: [{ id: "manual-model" }],
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "alpha-model",
|
||||
},
|
||||
{
|
||||
name: "beta-main",
|
||||
providerType: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
auth: "Needs attention",
|
||||
available: "no",
|
||||
models: "beta-model",
|
||||
},
|
||||
{
|
||||
name: "manual-main",
|
||||
providerType: "manual-type",
|
||||
label: "manual-type",
|
||||
auth: "not configured",
|
||||
available: "yes",
|
||||
models: "manual-model",
|
||||
},
|
||||
]);
|
||||
|
||||
const table = render(result, { format: "table", noColor: true });
|
||||
expect(table).toContain("Connected");
|
||||
expect(table).toContain("Needs attention");
|
||||
expect(table).toContain("not configured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider rm", () => {
|
||||
it("removes a configured model provider", async () => {
|
||||
const removedNames: string[] = [];
|
||||
|
||||
const result = await runRmCommand("alpha-main", { host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
removePaseoAgentProvider: async (name: string) => {
|
||||
removedNames.push(name);
|
||||
return {
|
||||
requestId: "remove-1",
|
||||
success: true,
|
||||
removed: true,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(removedNames).toEqual(["alpha-main"]);
|
||||
expect(result.data).toEqual({ name: "alpha-main", removed: "yes" });
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,52 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
renderTable,
|
||||
renderTableHeader,
|
||||
type CommandOptions,
|
||||
type ListResult,
|
||||
type OutputSchema,
|
||||
} from "../../output/index.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
export interface ProviderListItem {
|
||||
name: string;
|
||||
providerType: string;
|
||||
provider: ProviderSnapshotEntry["provider"];
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
status: string;
|
||||
enabled: "Enabled" | "Disabled";
|
||||
defaultMode: string;
|
||||
modes: string;
|
||||
}
|
||||
|
||||
interface ProviderLsClient extends Pick<
|
||||
DaemonClient,
|
||||
"waitForServerInfo" | "getPaseoAgentCatalog" | "getPaseoAgentProviders" | "close"
|
||||
> {}
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
|
||||
defaultMode: def.defaultModeId ?? "-",
|
||||
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
||||
}));
|
||||
|
||||
export interface ProviderLsDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderLsClient>;
|
||||
function getStaticProviders(): ProviderListItem[] {
|
||||
return PROVIDERS;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderLsDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
};
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: "name",
|
||||
idField: "provider",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 16 },
|
||||
{ header: "LABEL", field: "label", width: 22 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 30 },
|
||||
{ header: "PROVIDER", field: "provider", width: 12 },
|
||||
{ header: "LABEL", field: "label", width: 16 },
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
width: 12,
|
||||
color: (value) => {
|
||||
if (value === "available") return "green";
|
||||
if (value === "unavailable") return "red";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{ header: "ENABLED", field: "enabled", width: 10 },
|
||||
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
|
||||
{ header: "MODES", field: "modes", width: 30 },
|
||||
],
|
||||
renderHuman: (result, options) => {
|
||||
if (result.type === "list" && result.data.length === 0) {
|
||||
return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options);
|
||||
}
|
||||
return renderTable(result, options);
|
||||
},
|
||||
};
|
||||
|
||||
export type ProviderLsResult = ListResult<ProviderListItem>;
|
||||
@@ -60,54 +55,40 @@ export interface ProviderLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
function authState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||
if (!provider.auth) {
|
||||
return "not configured";
|
||||
}
|
||||
return provider.auth.configured ? "Connected" : "Needs attention";
|
||||
}
|
||||
|
||||
function catalogLabel(catalog: PaseoAgentCatalogEntry[], providerType: string): string {
|
||||
return catalog.find((entry) => entry.id === providerType)?.label ?? providerType;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: ProviderLsOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderLsDependencies> = {},
|
||||
): Promise<ProviderLsResult> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const catalogResult = await client.getPaseoAgentCatalog();
|
||||
if (catalogResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_CATALOG_FAILED",
|
||||
message: catalogResult.error,
|
||||
};
|
||||
}
|
||||
const providersResult = await client.getPaseoAgentProviders();
|
||||
if (providersResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_LIST_FAILED",
|
||||
message: providersResult.error,
|
||||
};
|
||||
}
|
||||
const client = await tryConnectToDaemon({ host: options.host });
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
type: "list",
|
||||
data: providersResult.providers.map((provider) => ({
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
label: catalogLabel(catalogResult.catalog, provider.providerType),
|
||||
auth: authState(provider),
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", ") || "-",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
return {
|
||||
type: "list",
|
||||
data: snapshot.entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
status: entry.status === "ready" ? "available" : entry.status,
|
||||
enabled: !entry.enabled ? "Disabled" : "Enabled",
|
||||
defaultMode: entry.defaultModeId ?? "default",
|
||||
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
interface ProviderRmOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface ProviderRemoveItem {
|
||||
name: string;
|
||||
removed: string;
|
||||
}
|
||||
|
||||
interface ProviderRmClient extends Pick<
|
||||
DaemonClient,
|
||||
"waitForServerInfo" | "removePaseoAgentProvider" | "close"
|
||||
> {}
|
||||
|
||||
export interface ProviderRmDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderRmClient>;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderRmDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
};
|
||||
|
||||
export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "REMOVED", field: "removed", width: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runRmCommand(
|
||||
name: string,
|
||||
options: ProviderRmOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderRmDependencies> = {},
|
||||
): Promise<SingleResult<ProviderRemoveItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const result = await client.removePaseoAgentProvider(name);
|
||||
if (!result.success) {
|
||||
throw {
|
||||
code: "PROVIDER_REMOVE_FAILED",
|
||||
message: result.error ?? "Daemon rejected the provider removal request.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
name,
|
||||
removed: result.removed ? "yes" : "no",
|
||||
},
|
||||
schema: providerRemoveSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ export function createScheduleCommand(): Command {
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option("--mode <mode>", "Provider-specific mode")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
|
||||
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { browserOpenCommand, openBrowserUrl } from "./open-browser.js";
|
||||
|
||||
describe("browserOpenCommand", () => {
|
||||
it("opens Windows URLs without cmd.exe shell parsing", () => {
|
||||
const url =
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback";
|
||||
|
||||
expect(browserOpenCommand(url, "win32")).toEqual({
|
||||
command: "rundll32.exe",
|
||||
args: ["url.dll,FileProtocolHandler", url],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps macOS opener behavior unchanged", () => {
|
||||
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
|
||||
|
||||
expect(browserOpenCommand(url, "darwin")).toEqual({
|
||||
command: "open",
|
||||
args: [url],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Linux opener behavior unchanged", () => {
|
||||
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
|
||||
|
||||
expect(browserOpenCommand(url, "linux")).toEqual({
|
||||
command: "xdg-open",
|
||||
args: [url],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBrowserUrl", () => {
|
||||
it("spawns the resolved opener without launching a real browser", () => {
|
||||
const spawned: Array<{
|
||||
command: string;
|
||||
args: string[];
|
||||
options: { stdio: "ignore"; detached: true };
|
||||
}> = [];
|
||||
const child = {
|
||||
on: () => child,
|
||||
unref: () => {},
|
||||
};
|
||||
|
||||
const opened = openBrowserUrl(
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
|
||||
{
|
||||
platform: "win32",
|
||||
spawn: (command, args, options) => {
|
||||
spawned.push({ command, args, options });
|
||||
return child;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(opened).toBe(true);
|
||||
expect(spawned).toEqual([
|
||||
{
|
||||
command: "rundll32.exe",
|
||||
args: [
|
||||
"url.dll,FileProtocolHandler",
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
|
||||
],
|
||||
options: { stdio: "ignore", detached: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns false when spawning the opener throws", () => {
|
||||
const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", {
|
||||
platform: "linux",
|
||||
spawn: () => {
|
||||
throw new Error("spawn failed");
|
||||
},
|
||||
});
|
||||
|
||||
expect(opened).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
interface BrowserOpenCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type BrowserOpenSpawn = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: "ignore"; detached: true },
|
||||
) => Pick<ChildProcessWithoutNullStreams, "on" | "unref">;
|
||||
|
||||
interface BrowserOpenDependencies {
|
||||
platform?: NodeJS.Platform;
|
||||
spawn?: BrowserOpenSpawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the
|
||||
* opener process was spawned, false otherwise. Callers must always print the URL too,
|
||||
* so a failed/headless open still lets the user copy it.
|
||||
*/
|
||||
export function browserOpenCommand(
|
||||
url: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): BrowserOpenCommand {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return { command: "open", args: [url] };
|
||||
case "win32":
|
||||
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
||||
default:
|
||||
return { command: "xdg-open", args: [url] };
|
||||
}
|
||||
}
|
||||
|
||||
export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean {
|
||||
const spawnBrowser = dependencies.spawn ?? spawn;
|
||||
const { command, args } = browserOpenCommand(url, dependencies.platform);
|
||||
|
||||
try {
|
||||
const child = spawnBrowser(command, args, { stdio: "ignore", detached: true });
|
||||
child.on("error", () => {});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,15 @@
|
||||
/**
|
||||
* Phase 15: Provider Command Tests
|
||||
*
|
||||
* Tests provider commands for configured Paseo Agent model providers and agent
|
||||
* provider model listing. This test uses an isolated daemon to avoid coupling to
|
||||
* a user's long-running daemon.
|
||||
* Tests provider commands for listing providers and models.
|
||||
* Provider ls data is static, while provider models are fetched via daemon integration.
|
||||
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
|
||||
*
|
||||
* Tests:
|
||||
* - provider --help shows subcommands
|
||||
* - provider ls on a fresh daemon prints an empty configured-provider table
|
||||
* - provider add stores an API-key model provider without network validation
|
||||
* - repeated provider add updates the same provider instance
|
||||
* - provider add rejects unknown catalog ids with known ids
|
||||
* - provider rm removes a configured model provider
|
||||
* - provider add uses catalog default models when present
|
||||
* - provider ls lists all providers
|
||||
* - provider ls --json outputs valid JSON
|
||||
* - provider ls --quiet outputs provider names only
|
||||
* - provider models claude lists claude models
|
||||
* - provider models codex lists codex models
|
||||
* - provider models opencode lists opencode models
|
||||
@@ -23,7 +20,14 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createE2ETestContext,
|
||||
createTempDirs,
|
||||
runPaseoCli,
|
||||
startTestDaemon,
|
||||
} from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Provider Commands ===\n");
|
||||
|
||||
@@ -34,26 +38,10 @@ interface ProviderModel {
|
||||
}
|
||||
|
||||
interface ProviderListRow {
|
||||
name: string;
|
||||
providerType: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface CliResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function assertExitCode(result: CliResult, expected: number, message: string): void {
|
||||
assert.strictEqual(result.exitCode, expected, `${message}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
|
||||
function assertNonZeroExitCode(result: CliResult, message: string): void {
|
||||
assert.notStrictEqual(result.exitCode, 0, `${message}\nstderr:\n${result.stderr}`);
|
||||
status: string;
|
||||
enabled: string;
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
@@ -143,24 +131,6 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
|
||||
return attemptRun(1);
|
||||
}
|
||||
|
||||
function parseProviderListJson(stdout: string): ProviderListRow[] {
|
||||
const data = JSON.parse(stdout.trim()) as ProviderListRow[];
|
||||
assert(Array.isArray(data), "provider ls --json output should be an array");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getProviderRows(): Promise<ProviderListRow[]> {
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assertExitCode(result, 0, "provider ls --json should exit 0");
|
||||
return parseProviderListJson(result.stdout);
|
||||
}
|
||||
|
||||
function assertProviderTableHeader(stdout: string): void {
|
||||
for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) {
|
||||
assert(stdout.includes(header), `provider ls table should include ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertClaudeModels(data: ProviderModel[]): void {
|
||||
assert.strictEqual(
|
||||
data.length,
|
||||
@@ -195,137 +165,144 @@ try {
|
||||
{
|
||||
console.log("Test 1: provider --help shows subcommands");
|
||||
const result = await ctx.paseo(["provider", "--help"]);
|
||||
assertExitCode(result, 0, "provider --help should exit 0");
|
||||
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls");
|
||||
assert(result.stdout.includes("add"), "help should mention add");
|
||||
assert(result.stdout.includes("rm"), "help should mention rm");
|
||||
assert(result.stdout.includes("models"), "help should mention models");
|
||||
console.log("✓ provider --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: provider ls on a fresh daemon shows no configured providers
|
||||
// Test 2: provider ls lists all providers
|
||||
{
|
||||
console.log("Test 2: provider ls on a fresh daemon shows no configured providers");
|
||||
console.log("Test 2: provider ls lists all providers");
|
||||
const result = await ctx.paseo(["provider", "ls"]);
|
||||
assertExitCode(result, 0, "provider ls should exit 0");
|
||||
assertProviderTableHeader(result.stdout);
|
||||
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
|
||||
|
||||
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assertExitCode(jsonResult, 0, "provider ls --json should exit 0");
|
||||
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
|
||||
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
|
||||
}
|
||||
|
||||
// Test 3: provider add openrouter stores a dummy key without network validation
|
||||
{
|
||||
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add should exit 0");
|
||||
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
|
||||
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
|
||||
assert(result.stdout.includes("yes"), "add output should show the provider as available");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
|
||||
assert.deepStrictEqual(rows[0], {
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
label: "OpenRouter",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
console.log("✓ provider add openrouter stores a dummy key without network validation\n");
|
||||
}
|
||||
|
||||
// Test 4: provider add is idempotent for the same instance name
|
||||
{
|
||||
console.log("Test 4: provider add is idempotent for the same instance name");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key-2\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add should exit 0");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
|
||||
assert.strictEqual(rows[0]?.name, "openrouter");
|
||||
assert.strictEqual(rows[0]?.label, "OpenRouter");
|
||||
console.log("✓ provider add is idempotent for the same instance name\n");
|
||||
}
|
||||
|
||||
// Test 5: provider add rejects unknown catalog ids with known ids
|
||||
{
|
||||
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
|
||||
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
|
||||
stdin: "dummy-key\n",
|
||||
});
|
||||
assertNonZeroExitCode(result, "provider add should fail for unknown ids");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(output.includes("nonsense-id"), "error should mention the requested id");
|
||||
assert(output.includes("Known provider ids"), "error should mention known provider ids");
|
||||
assert(output.includes("openrouter"), "known ids should include openrouter");
|
||||
assert(output.includes("kimi"), "known ids should include kimi");
|
||||
console.log("✓ provider add rejects unknown catalog ids with known ids\n");
|
||||
}
|
||||
|
||||
// Test 6: provider rm removes a configured provider
|
||||
{
|
||||
console.log("Test 6: provider rm removes a configured provider");
|
||||
const result = await ctx.paseo(["provider", "rm", "openrouter"]);
|
||||
assertExitCode(result, 0, "provider rm should exit 0");
|
||||
assert(result.stdout.includes("openrouter"), "rm output should include the instance name");
|
||||
assert(result.stdout.includes("yes"), "rm output should report removal");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter");
|
||||
const table = await ctx.paseo(["provider", "ls"]);
|
||||
assertExitCode(table, 0, "provider ls should stay successful after removal");
|
||||
assertProviderTableHeader(table.stdout);
|
||||
console.log("✓ provider rm removes a configured provider\n");
|
||||
}
|
||||
|
||||
// Test 7: provider add uses catalog default models when present
|
||||
{
|
||||
console.log("Test 7: provider add uses catalog default models when present");
|
||||
const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], {
|
||||
stdin: "dummy-kimi-key\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add kimi should exit 0");
|
||||
assert(result.stdout.includes("kimi"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
const kimi = rows.find((row) => row.name === "kimi");
|
||||
assert(kimi, "provider ls should include the kimi instance");
|
||||
assert.strictEqual(kimi.label, "Kimi Coding Plan");
|
||||
assert.strictEqual(kimi.auth, "Connected");
|
||||
assert.strictEqual(kimi.available, "yes");
|
||||
assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models");
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
|
||||
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
|
||||
assert(
|
||||
kimi.models
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean).length > 0,
|
||||
"kimi should list at least one catalog-derived model id",
|
||||
result.stdout.includes("available") ||
|
||||
result.stdout.includes("loading") ||
|
||||
result.stdout.includes("unavailable"),
|
||||
"output should show a provider status",
|
||||
);
|
||||
console.log("✓ provider add uses catalog default models when present\n");
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
}
|
||||
|
||||
// Test 8: provider models claude lists canonical model aliases
|
||||
// Test 3: provider ls --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 8: provider models claude lists canonical model aliases");
|
||||
console.log("Test 3: provider ls --json outputs valid JSON");
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "codex"),
|
||||
"should include codex",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
const rows = data as ProviderListRow[];
|
||||
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||
const row = rows.find((p) => p.provider === provider);
|
||||
assert(row, `should include ${provider}`);
|
||||
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
|
||||
}
|
||||
|
||||
const omp = rows.find((p) => p.provider === "omp");
|
||||
assert(omp, "should include omp");
|
||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 4: provider ls includes disabled providers
|
||||
{
|
||||
console.log("Test 4: provider ls includes disabled providers");
|
||||
const { paseoHome, workDir } = await createTempDirs();
|
||||
await writeFile(
|
||||
join(paseoHome, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
|
||||
try {
|
||||
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
|
||||
const claude = data.find((p) => p.provider === "claude");
|
||||
assert(claude, "disabled claude provider should stay in provider ls");
|
||||
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
|
||||
|
||||
const opencode = data.find((p) => p.provider === "opencode");
|
||||
assert(opencode, "enabled opencode provider should stay in provider ls");
|
||||
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
|
||||
|
||||
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
|
||||
assert.notStrictEqual(
|
||||
modelsResult.exitCode,
|
||||
0,
|
||||
"provider models should fail for disabled providers",
|
||||
);
|
||||
const output = modelsResult.stdout + modelsResult.stderr;
|
||||
assert(
|
||||
output.includes("Provider claude is disabled"),
|
||||
"provider models should surface the daemon disabled error",
|
||||
);
|
||||
assert(
|
||||
!output.includes("claude-sonnet"),
|
||||
"provider models should not print fallback models for disabled providers",
|
||||
);
|
||||
} finally {
|
||||
await disabledCtx.stop();
|
||||
}
|
||||
console.log("✓ provider ls includes disabled providers\n");
|
||||
}
|
||||
|
||||
// Test 5: provider ls --quiet outputs provider names only
|
||||
{
|
||||
console.log("Test 5: provider ls --quiet outputs provider names only");
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
// Test 6: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log("Test 6: provider models claude lists canonical model aliases");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assertClaudeModels(data);
|
||||
console.log("✓ provider models claude lists canonical model aliases\n");
|
||||
}
|
||||
|
||||
// Test 9: provider models codex includes concrete codex model IDs
|
||||
// Test 7: provider models codex includes concrete codex model IDs
|
||||
{
|
||||
console.log("Test 9: provider models codex includes concrete codex model IDs");
|
||||
console.log("Test 7: provider models codex includes concrete codex model IDs");
|
||||
const data = await runProviderModelsJson("codex");
|
||||
assert(data.length >= 1, "codex model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -345,9 +322,9 @@ try {
|
||||
console.log("✓ provider models codex includes concrete codex model IDs\n");
|
||||
}
|
||||
|
||||
// Test 10: provider models opencode returns namespaced model IDs
|
||||
// Test 8: provider models opencode returns namespaced model IDs
|
||||
{
|
||||
console.log("Test 10: provider models opencode returns namespaced model IDs");
|
||||
console.log("Test 8: provider models opencode returns namespaced model IDs");
|
||||
const data = await runProviderModelsJson("opencode");
|
||||
assert(data.length >= 1, "opencode model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -366,11 +343,11 @@ try {
|
||||
console.log("✓ provider models opencode returns namespaced model IDs\n");
|
||||
}
|
||||
|
||||
// Test 11: provider models unknown fails with error
|
||||
// Test 9: provider models unknown fails with error
|
||||
{
|
||||
console.log("Test 11: provider models unknown fails with error");
|
||||
console.log("Test 9: provider models unknown fails with error");
|
||||
const result = await ctx.paseo(["provider", "models", "unknown"]);
|
||||
assertNonZeroExitCode(result, "should fail for unknown provider");
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
|
||||
@@ -379,9 +356,9 @@ try {
|
||||
console.log("✓ provider models unknown fails with error\n");
|
||||
}
|
||||
|
||||
// Test 12: provider models --json outputs valid JSON
|
||||
// Test 10: provider models --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 12: provider models --json outputs valid JSON");
|
||||
console.log("Test 10: provider models --json outputs valid JSON");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(
|
||||
@@ -394,15 +371,15 @@ try {
|
||||
console.log("✓ provider models --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 13: provider models --quiet outputs model IDs only
|
||||
// Test 11: provider models --quiet outputs model IDs only
|
||||
{
|
||||
console.log("Test 13: provider models --quiet outputs model IDs only");
|
||||
console.log("Test 11: provider models --quiet outputs model IDs only");
|
||||
assert(
|
||||
claudeModelIdsFromJson.length > 0,
|
||||
"claude model IDs should be captured from --json output",
|
||||
);
|
||||
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
|
||||
assertExitCode(result, 0, "provider models claude --quiet should exit 0");
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||
assert.strictEqual(
|
||||
lines.length,
|
||||
|
||||
@@ -43,13 +43,6 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
const TEST_DAEMON_HOST = "127.0.0.1";
|
||||
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
|
||||
// local developer credentials cannot hide CI-missing-auth failures.
|
||||
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
|
||||
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
||||
@@ -66,14 +59,6 @@ function createOutputCapture(): OutputCapture {
|
||||
return { value: "", truncated: false };
|
||||
}
|
||||
|
||||
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const scrubbed = { ...env };
|
||||
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
|
||||
delete scrubbed[key];
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
|
||||
const next = target.value + chunk.toString();
|
||||
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
|
||||
@@ -249,7 +234,7 @@ export async function startTestDaemon(options?: {
|
||||
|
||||
// Start daemon process using tsx to run TypeScript directly
|
||||
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
|
||||
env: scrubPaseoAgentProviderAuthEnv({
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
@@ -257,7 +242,7 @@ export async function startTestDaemon(options?: {
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
@@ -351,7 +336,6 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -362,15 +346,15 @@ export async function runPaseoCli(
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
|
||||
env: scrubPaseoAgentProviderAuthEnv({
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
|
||||
PASEO_HOME: ctx.paseoHome,
|
||||
...options?.env,
|
||||
}),
|
||||
},
|
||||
cwd,
|
||||
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
@@ -385,10 +369,6 @@ export async function runPaseoCli(
|
||||
appendOutputCapture(stderr, data);
|
||||
});
|
||||
|
||||
if (options?.stdin !== undefined) {
|
||||
proc.stdin?.end(options.stdin);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (proc.pid) {
|
||||
signalProcessTree(proc.pid, "SIGKILL");
|
||||
@@ -426,7 +406,7 @@ export async function createE2ETestContext(options?: {
|
||||
/** Run a paseo CLI command against this daemon */
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
@@ -438,7 +418,7 @@ export async function createE2ETestContext(options?: {
|
||||
|
||||
const paseo = (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => runPaseoCli(ctx, args, opts);
|
||||
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user