mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc47701ad | ||
|
|
6a2b5ea5ea | ||
|
|
269aa53c27 | ||
|
|
03b45f8e11 | ||
|
|
02ec891fe4 | ||
|
|
5d67032ef6 | ||
|
|
d6e4e5f3ce | ||
|
|
2ba7562dff | ||
|
|
363e76858f | ||
|
|
27e713671d | ||
|
|
4e5737169f | ||
|
|
fce3d14a83 | ||
|
|
343dd2d10d | ||
|
|
a410617d4a | ||
|
|
4a8a841f72 | ||
|
|
7a6d2d86d7 | ||
|
|
341af162b0 | ||
|
|
96067da1d3 | ||
|
|
888f28990b | ||
|
|
b1abec4512 | ||
|
|
1a084eb8b0 | ||
|
|
42ab7927f2 | ||
|
|
56d908c6dc | ||
|
|
2cfb29f1f0 | ||
|
|
c3827466eb | ||
|
|
c52482368b | ||
|
|
8089c802f0 | ||
|
|
3e535aa2d7 | ||
|
|
4063671e3f | ||
|
|
9665ba07a3 | ||
|
|
4086f65096 | ||
|
|
44afecc366 | ||
|
|
c9cd414571 | ||
|
|
2263469342 | ||
|
|
5a0ea3385e | ||
|
|
3708eddbcd | ||
|
|
f41dde8c72 | ||
|
|
d3e8f77914 | ||
|
|
48d07dedb5 | ||
|
|
31e9a210d0 | ||
|
|
bf5e3b47e7 | ||
|
|
b8b66816ca | ||
|
|
2b46caa20e | ||
|
|
20385bdb50 | ||
|
|
db03b1f3fd | ||
|
|
cb486c3a5a | ||
|
|
7dad7a377c |
@@ -241,6 +241,11 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -280,7 +285,7 @@ jobs:
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -288,7 +293,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.103 - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -36,8 +36,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [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/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 |
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## コミュニティ
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ npm run typecheck
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 社区
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
|
||||
@@ -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.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are 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 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.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
When running a dedicated Electron QA instance against a non-default Expo port, set
|
||||
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
|
||||
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
|
||||
|
||||
### React render profiling
|
||||
|
||||
The app has a gated React render profiler in
|
||||
|
||||
+15
-7
@@ -47,18 +47,27 @@ dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||
leaf workspace URL.
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, express only the destination with `navigateToWorkspace()`.
|
||||
Do not make the caller branch on its current route.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
root host route and pass the nested workspace screen, or Expo Router can append
|
||||
extra hidden workspace deck entries.
|
||||
root host route and pass the nested workspace screen when a host route is
|
||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
||||
The workspace navigation helper inspects the mounted navigation state to make
|
||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
|
||||
Hidden host routes may keep their local params while an app-wide route is
|
||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
||||
only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
@@ -112,8 +121,7 @@ Before landing route changes:
|
||||
|
||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||
- [ ] Did an app-wide route return to a workspace? Use
|
||||
`navigateToHostWorkspaceRoute()`.
|
||||
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
|
||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||
|
||||
+9
-4
@@ -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** — 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".
|
||||
- **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".
|
||||
- **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,11 +17,13 @@ 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 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`).
|
||||
- **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.
|
||||
- **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.
|
||||
- **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`).
|
||||
- **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`).
|
||||
- **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).
|
||||
@@ -39,5 +41,8 @@ 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`).
|
||||
|
||||
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `which opencode`: `opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# 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.
|
||||
@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ 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-uGqJm/y14KJYBwutBkPwxhNbStzpeeVx2PcB081r4nk=
|
||||
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
|
||||
|
||||
@@ -52,6 +52,7 @@ 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
+2898
-52
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
@@ -153,6 +153,13 @@ async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||
page: Page,
|
||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||
@@ -351,7 +358,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
@@ -384,10 +391,28 @@ test.describe("Composer autocomplete", () => {
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await openSettingsThenBackToWorkspace(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: 2,
|
||||
serverId,
|
||||
workspaceId: second.workspaceId,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: third.workspaceId,
|
||||
});
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||
|
||||
@@ -10,6 +10,7 @@ import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
@@ -405,15 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
);
|
||||
}
|
||||
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
@@ -675,7 +667,7 @@ interface DaemonSpawnArgs {
|
||||
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
@@ -684,21 +676,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
// Default app E2E does not cover speech flows. Keep these disabled so
|
||||
// unrelated tests never start background local-model downloads.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete env[key];
|
||||
}
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
|
||||
@@ -5,6 +5,7 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,29 @@ import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
@@ -171,33 +194,39 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
||||
// until the test explicitly allows recovery. Closing the transport leaves the
|
||||
// client-side RPC pending across reconnects, so this injects the same correlated
|
||||
// rpc_error shape the daemon emits for failed async session requests.
|
||||
export async function installReadTransportFailure(
|
||||
page: Page,
|
||||
): Promise<{ allowRecovery: () => void }> {
|
||||
let shouldFailReads = true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId === "string") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "read_project_config_request",
|
||||
error: "Test read transport failure.",
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
@@ -214,6 +243,12 @@ export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
allowRecovery() {
|
||||
shouldFailReads = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
|
||||
@@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
|
||||
): Promise<void> {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||
await expect(
|
||||
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
// Nav tabs only render for multi-question cards (hidden for a lone question).
|
||||
if (input.total > 1) {
|
||||
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await page
|
||||
// Options render as radios (single-select) or checkboxes (multi-select), so match
|
||||
// either role by accessible name.
|
||||
function questionOption(page: Page, option: string) {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
|
||||
}
|
||||
|
||||
// The multi-question nav renders as a tablist; each question is a tab.
|
||||
function questionNavTab(page: Page, input: { index: number; total: number }) {
|
||||
return page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: option })
|
||||
.click();
|
||||
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await questionOption(page, option).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||
.click();
|
||||
await questionNavTab(page, input).click();
|
||||
}
|
||||
|
||||
export async function expectQuestionNavigationEnabled(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toBeEnabled();
|
||||
await expect(questionNavTab(page, input)).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
import { type SeededWorkspace } from "./seed-client";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||
const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||
|
||||
export interface FakeScheduleHostWorkspace {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildFakeProviderEntries(nowIso: string) {
|
||||
return [
|
||||
{
|
||||
provider: "mock",
|
||||
label: "Mock",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
fetchedAt: nowIso,
|
||||
models: [
|
||||
{
|
||||
provider: "mock",
|
||||
id: FAKE_HOST_MODEL_ID,
|
||||
label: FAKE_HOST_MODEL_LABEL,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modes: [{ id: "load-test", label: "Load test" }],
|
||||
defaultModeId: "load-test",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
|
||||
const parsed = parseJson(message);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envelope = parsed as { type?: string; message?: SessionRequest };
|
||||
if (envelope.type !== "session" || !envelope.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelope.message;
|
||||
}
|
||||
|
||||
function getRequestId(request: SessionRequest): string {
|
||||
return typeof request.requestId === "string" ? request.requestId : "fake-request";
|
||||
}
|
||||
|
||||
export async function buildFakeScheduleHostWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<FakeScheduleHostWorkspace> {
|
||||
const workspaceList = await workspace.client.fetchWorkspaces({
|
||||
filter: { projectId: workspace.projectId },
|
||||
});
|
||||
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
|
||||
if (!baseWorkspace) {
|
||||
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
|
||||
}
|
||||
|
||||
const projectId = `${workspace.projectId}-fake-host`;
|
||||
const cwd = `${workspace.repoPath}-fake-host`;
|
||||
return {
|
||||
serverId: "schedule-fake-host",
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
id: `${baseWorkspace.id}-fake-host`,
|
||||
projectId,
|
||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
projectRootPath: cwd,
|
||||
workspaceDirectory: cwd,
|
||||
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||
project: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function installFakeScheduleHost(input: {
|
||||
page: Page;
|
||||
port: string;
|
||||
serverId: string;
|
||||
workspace: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||
ws.onMessage((message) => {
|
||||
const parsed = parseJson(message);
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
|
||||
ws.send(
|
||||
buildSessionMessage("status", {
|
||||
status: "server_info",
|
||||
serverId: input.serverId,
|
||||
hostname: "fake-schedule-host",
|
||||
version: "0.0.0-e2e",
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = readSessionRequest(message);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = getRequestId(request);
|
||||
const now = Date.now();
|
||||
const nowIso = new Date(now).toISOString();
|
||||
switch (request.type) {
|
||||
case "ping":
|
||||
ws.send(
|
||||
buildSessionMessage("pong", {
|
||||
requestId,
|
||||
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
|
||||
serverReceivedAt: now,
|
||||
serverSentAt: now,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_workspaces_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_workspaces_response", {
|
||||
requestId,
|
||||
entries: [input.workspace],
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "fetch_agents_request":
|
||||
ws.send(
|
||||
buildSessionMessage("fetch_agents_response", {
|
||||
requestId,
|
||||
entries: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "get_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("get_providers_snapshot_response", {
|
||||
requestId,
|
||||
entries: buildFakeProviderEntries(nowIso),
|
||||
generatedAt: nowIso,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "refresh_providers_snapshot_request":
|
||||
ws.send(
|
||||
buildSessionMessage("refresh_providers_snapshot_response", {
|
||||
requestId,
|
||||
acknowledged: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case "schedule/list":
|
||||
ws.send(
|
||||
buildSessionMessage("schedule/list/response", {
|
||||
requestId,
|
||||
schedules: [],
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function addFakeScheduleHostAndReload(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
label: string;
|
||||
port: string;
|
||||
}): Promise<void> {
|
||||
const host = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await input.page.evaluate(
|
||||
({ seededHost, keys }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
||||
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
seededHost: host,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await input.page.reload();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
const DISABLED_E2E_SPEECH_ENV = {
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
} as const;
|
||||
|
||||
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
// Default app E2E does not cover speech flows; keep restarts from starting
|
||||
// background local-model downloads for unrelated tests.
|
||||
const next: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
...DISABLED_E2E_SPEECH_ENV,
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
@@ -105,6 +106,54 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
const otherProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "aa-new-workspace-archive-other-",
|
||||
});
|
||||
const rememberedProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "zz-new-workspace-archive-remembered-",
|
||||
});
|
||||
const serverId = getServerId();
|
||||
const draftText = "keep this new workspace draft";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId,
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page
|
||||
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await composer.fill(draftText);
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
|
||||
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
|
||||
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
await expect(composer).toHaveValue(draftText);
|
||||
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
|
||||
} finally {
|
||||
await otherProject.cleanup();
|
||||
await rememberedProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
// Reject read_project_config_request calls until the user clicks Reload.
|
||||
// This keeps automatic reconnect refetches from racing past the callout.
|
||||
const transportFailure = await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
@@ -292,7 +292,8 @@ test.describe("Projects settings — error UX", () => {
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
// Retry Reload until the refetch wins any in-flight error-state rendering.
|
||||
transportFailure.allowRecovery();
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
|
||||
return result.schedule.id;
|
||||
}
|
||||
|
||||
function ignoreScheduleDeleteError(): void {}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
await (workspace.client as unknown as ScheduleSeedClient)
|
||||
.scheduleDelete({ id })
|
||||
.catch(ignoreScheduleDeleteError);
|
||||
}
|
||||
|
||||
test.describe("Schedules", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
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 row.click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
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";
|
||||
|
||||
interface ScheduleListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
target: { type: string; config?: { cwd?: string } };
|
||||
}
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: /select model/i }).click();
|
||||
const popup = page.getByTestId("combobox-desktop-container");
|
||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
||||
await popup.getByText(label, { exact: true }).click();
|
||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
||||
if (schedule) {
|
||||
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectScheduleCreatedForProject(input: {
|
||||
workspace: SeededWorkspace;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||
const list = await client.scheduleList();
|
||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
||||
expect(schedule).toEqual(
|
||||
expect.objectContaining({
|
||||
name: input.name,
|
||||
target: expect.objectContaining({
|
||||
type: "new-agent",
|
||||
config: expect.objectContaining({
|
||||
cwd: input.workspace.repoPath,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Schedules project target", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
await cleanup();
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Project schedule ${Date.now()}`;
|
||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
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 page.getByRole("button", { name: "New schedule" }).click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
||||
await page.getByRole("button", { name: "Cron" }).click();
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
||||
});
|
||||
|
||||
test("clears the selected model when the chosen project moves to another host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
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("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
workspace.projectDisplayName,
|
||||
);
|
||||
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||
fakeHost.projectDisplayName,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: input.paseoHome,
|
||||
PASEO_SERVER_ID: SERVER_ID,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: input.origin,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -256,9 +256,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
|
||||
|
||||
await expectHostConnectingOrOffline(page);
|
||||
await expectMenuButtonVisible(page);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102",
|
||||
"version": "0.1.103",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -87,6 +87,7 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
@@ -133,14 +134,13 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,6 +313,9 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
if (hasConfiguredLocalDaemonOverride()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
@@ -889,6 +892,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
(pathname === "/open-project" ||
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
@@ -947,6 +951,7 @@ function RootStack() {
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -17,7 +17,6 @@ export default function HostAgentReadyRoute() {
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -53,10 +52,9 @@ function HostAgentReadyRouteContent() {
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -96,7 +94,6 @@ function HostAgentReadyRouteContent() {
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +111,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||
|
||||
export default function SchedulesRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SchedulesScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { BrowserAnnotationIntent, 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,6 +36,13 @@ 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,
|
||||
@@ -89,10 +96,11 @@ export function getWorkspaceAttachmentPillContent(
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
const intent = attachment.attachment.intent;
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
|
||||
@@ -21,6 +21,12 @@ 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;
|
||||
@@ -42,6 +48,16 @@ export interface BrowserElementAttachment {
|
||||
} | null;
|
||||
parentChain: string[];
|
||||
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;
|
||||
* referenced by id so the draft-store GC keeps it alive.
|
||||
*/
|
||||
screenshot?: AttachmentMetadata;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
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" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
|
||||
class FakeDaemonClient {
|
||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||
|
||||
public on(
|
||||
type: "browser.automation.execute.request",
|
||||
handler: (request: BrowserAutomationExecuteRequest) => void,
|
||||
): () => void {
|
||||
expect(type).toBe("browser.automation.execute.request");
|
||||
this.handler = handler;
|
||||
return () => {
|
||||
if (this.handler === handler) {
|
||||
this.handler = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
|
||||
this.sentResponses.push(response);
|
||||
}
|
||||
|
||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||
this.handler?.(nextRequest);
|
||||
}
|
||||
}
|
||||
|
||||
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-1",
|
||||
command: { command: "list_tabs", args: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
||||
return {
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-new",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "/repo",
|
||||
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
|
||||
};
|
||||
}
|
||||
|
||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
return {
|
||||
requestId,
|
||||
ok: true as const,
|
||||
result: {
|
||||
command: "list_tabs" as const,
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
||||
return {
|
||||
requestId,
|
||||
ok: true as const,
|
||||
result: {
|
||||
command: "list_tabs" as const,
|
||||
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",
|
||||
url: browser.url,
|
||||
title: browser.title,
|
||||
isActive: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("mountBrowserAutomationHandler", () => {
|
||||
beforeEach(() => {
|
||||
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());
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo",
|
||||
});
|
||||
if (!workspaceKey) throw new Error("expected workspace key");
|
||||
const focusedTabId = 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,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
|
||||
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 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);
|
||||
});
|
||||
|
||||
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,
|
||||
serverId: "server-1",
|
||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs: 1,
|
||||
registrationPollIntervalMs: 1,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await waitForAsyncWork();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_timeout",
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
||||
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,
|
||||
});
|
||||
|
||||
client.receive(browserNewTabRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses[0]?.payload).toEqual({
|
||||
requestId: "req-new",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unknown_error",
|
||||
message: "IPC registration check failed",
|
||||
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,
|
||||
});
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
||||
expect(client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: true,
|
||||
result: { command: "list_tabs", tabs: [] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing bridge sends browser_unsupported", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
||||
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(client.sentResponses).toEqual([
|
||||
{
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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"');
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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 } }),
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
client.receive(browserAutomationRequest());
|
||||
await flushPromises();
|
||||
|
||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
||||
expect(client.sentResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
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 {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
||||
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
|
||||
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
|
||||
|
||||
interface BrowserAutomationClient {
|
||||
on(
|
||||
type: "browser.automation.execute.request",
|
||||
handler: (message: BrowserAutomationExecuteRequest) => void,
|
||||
): () => void;
|
||||
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
|
||||
}
|
||||
|
||||
export interface BrowserAutomationHandlerOptions {
|
||||
client: BrowserAutomationClient;
|
||||
serverId?: string;
|
||||
getHost?: () => DesktopHostBridge | null;
|
||||
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationHandler(
|
||||
options: BrowserAutomationHandlerOptions,
|
||||
): () => void {
|
||||
const getHost = options.getHost ?? getDesktopHost;
|
||||
return options.client.on("browser.automation.execute.request", (request) => {
|
||||
void handleBrowserAutomationRequest({
|
||||
client: options.client,
|
||||
getHost,
|
||||
request,
|
||||
serverId: options.serverId,
|
||||
ensureResidentBrowserWebview:
|
||||
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
|
||||
...(options.registrationWaitTimeoutMs !== undefined
|
||||
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
|
||||
: {}),
|
||||
...(options.registrationPollIntervalMs !== undefined
|
||||
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function mountBrowserAutomationDaemonClientHandler(
|
||||
client: unknown,
|
||||
options?: { serverId?: string },
|
||||
): () => void {
|
||||
return mountBrowserAutomationHandler({
|
||||
client: client as BrowserAutomationClient,
|
||||
...(options?.serverId ? { serverId: options.serverId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBrowserAutomationRequest(params: {
|
||||
client: BrowserAutomationHandlerOptions["client"];
|
||||
getHost: () => DesktopHostBridge | null;
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
client,
|
||||
getHost,
|
||||
request,
|
||||
serverId,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs,
|
||||
registrationPollIntervalMs,
|
||||
} = params;
|
||||
const browserHost = getHost()?.browser;
|
||||
const executeAutomationCommand = browserHost?.executeAutomationCommand;
|
||||
|
||||
if (request.command.command === "new_tab") {
|
||||
try {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: await openBrowserTabForRequest({
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
ensureResidentBrowserWebview,
|
||||
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
|
||||
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await rememberAgentBrowserTarget({ request, browserHost });
|
||||
|
||||
if (!executeAutomationCommand) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not available in this app runtime.",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await executeAutomationCommand(request);
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeBridgePayload(request.requestId, payload),
|
||||
});
|
||||
} catch (error) {
|
||||
client.sendBrowserAutomationExecuteResponse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: normalizeThrownBridgeError(request.requestId, error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowserTabForRequest(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
serverId?: string;
|
||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
||||
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
|
||||
registrationWaitTimeoutMs?: number;
|
||||
registrationPollIntervalMs?: number;
|
||||
}): Promise<BrowserAutomationResponsePayload> {
|
||||
const {
|
||||
request,
|
||||
serverId,
|
||||
browserHost,
|
||||
ensureResidentBrowserWebview,
|
||||
registrationWaitTimeoutMs,
|
||||
registrationPollIntervalMs,
|
||||
} = params;
|
||||
const command = request.command as Extract<
|
||||
BrowserAutomationExecuteRequest["command"],
|
||||
{ command: "new_tab" }
|
||||
>;
|
||||
const workspaceId = request.workspaceId ?? command.args.workspaceId;
|
||||
if (!serverId || !workspaceId) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_no_tab",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
|
||||
const url = command.args.url ?? "https://example.com";
|
||||
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!workspaceKey) {
|
||||
return browserAutomationFailure({
|
||||
requestId: request.requestId,
|
||||
code: "browser_no_tab",
|
||||
message: "Cannot create a browser tab without a workspace context.",
|
||||
});
|
||||
}
|
||||
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
|
||||
kind: "browser",
|
||||
browserId,
|
||||
});
|
||||
|
||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
if (request.agentId) {
|
||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
||||
}
|
||||
|
||||
if (browserHost?.executeAutomationCommand) {
|
||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||
const registered = await waitForBrowserRegistration({
|
||||
request,
|
||||
browserId,
|
||||
workspaceId,
|
||||
executeAutomationCommand: browserHost.executeAutomationCommand,
|
||||
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
|
||||
...(registrationPollIntervalMs !== undefined
|
||||
? { pollIntervalMs: registrationPollIntervalMs }
|
||||
: {}),
|
||||
});
|
||||
if (!registered) {
|
||||
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.`,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForBrowserRegistration(params: {
|
||||
request: BrowserAutomationExecuteRequest;
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
executeAutomationCommand: (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
) => Promise<BrowserAutomationResponsePayload>;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<boolean> {
|
||||
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
|
||||
while (Date.now() < deadline) {
|
||||
const payload = await params.executeAutomationCommand({
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: `${params.request.requestId}:list_tabs`,
|
||||
agentId: params.request.agentId,
|
||||
cwd: params.request.cwd,
|
||||
workspaceId: params.workspaceId,
|
||||
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
|
||||
});
|
||||
if (payload.ok && payload.result.command === "list_tabs") {
|
||||
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await delay(params.pollIntervalMs ?? 100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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,
|
||||
): BrowserAutomationResponsePayload {
|
||||
return { ...payload, requestId } as BrowserAutomationResponsePayload;
|
||||
}
|
||||
|
||||
function normalizeThrownBridgeError(
|
||||
requestId: string,
|
||||
error: unknown,
|
||||
): BrowserAutomationFailurePayload {
|
||||
const typed = readTypedBrowserAutomationError(error);
|
||||
if (typed) {
|
||||
return browserAutomationFailure({ requestId, ...typed });
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("No handler registered")) {
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unsupported",
|
||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||
});
|
||||
}
|
||||
|
||||
return browserAutomationFailure({
|
||||
requestId,
|
||||
code: "browser_unknown_error",
|
||||
message: message || "Desktop browser automation failed.",
|
||||
});
|
||||
}
|
||||
|
||||
function readTypedBrowserAutomationError(
|
||||
value: unknown,
|
||||
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
|
||||
return null;
|
||||
}
|
||||
if (typeof record.message !== "string" || record.message.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: record.code as BrowserAutomationErrorCode,
|
||||
message: record.message,
|
||||
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function browserAutomationFailure(params: {
|
||||
requestId: string;
|
||||
code: BrowserAutomationErrorCode;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
}): BrowserAutomationFailurePayload {
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
ok: false,
|
||||
error: {
|
||||
code: params.code,
|
||||
message: params.message,
|
||||
retryable: params.retryable ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
@@ -178,6 +179,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
desktopScrollContainer: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
desktopScroll: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
@@ -451,6 +457,11 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Render the themed desktop-web scrollbar over the scroll area instead of the
|
||||
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
|
||||
*/
|
||||
webScrollbar?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -464,11 +475,16 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
webScrollbar = false,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const desktopScrollRef = useRef<ScrollView>(null);
|
||||
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
||||
enabled: webScrollbar && !isMobile,
|
||||
});
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
@@ -574,13 +590,22 @@ export function AdaptiveModalSheet({
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<ScrollView
|
||||
ref={desktopScrollRef}
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onLayout={desktopScrollbar.onLayout}
|
||||
onScroll={desktopScrollbar.onScroll}
|
||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!webScrollbar}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{desktopScrollbar.overlay}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "./browser-webview-resident";
|
||||
|
||||
describe("resident browser webviews", () => {
|
||||
afterEach(() => {
|
||||
clearResidentBrowserWebviewsForTests();
|
||||
});
|
||||
|
||||
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
|
||||
const host = document.createElement("div");
|
||||
const webview = document.createElement("webview");
|
||||
host.appendChild(webview);
|
||||
document.body.appendChild(host);
|
||||
|
||||
releaseResidentBrowserWebview("browser-a", webview);
|
||||
|
||||
expect(host.children).toHaveLength(0);
|
||||
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();
|
||||
});
|
||||
|
||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-agent",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(webview).not.toBeNull();
|
||||
expect(webview?.isConnected).toBe(true);
|
||||
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
|
||||
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
|
||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a resident webview when its browser tab closes", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
browserId: "browser-closed",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
removeResidentBrowserWebview("browser-closed");
|
||||
|
||||
expect(webview?.isConnected).toBe(false);
|
||||
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||
|
||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readDocument(): Document | null {
|
||||
return typeof document === "undefined" ? null : document;
|
||||
}
|
||||
|
||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||
if (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";
|
||||
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.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
||||
return element as HTMLElement;
|
||||
}
|
||||
}
|
||||
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`;
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
}
|
||||
|
||||
export function prepareBrowserWebview(
|
||||
webview: HTMLElement,
|
||||
input: { browserId: string; initialUrl?: string | null },
|
||||
): void {
|
||||
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
|
||||
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
|
||||
webview.setAttribute("allowpopups", "true");
|
||||
webview.setAttribute("spellcheck", "false");
|
||||
webview.setAttribute("autosize", "on");
|
||||
if (input.initialUrl) {
|
||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureResidentBrowserWebview(input: {
|
||||
browserId: string;
|
||||
url: string;
|
||||
}): HTMLElement | null {
|
||||
const browserId = trimNonEmpty(input.browserId);
|
||||
if (!browserId) {
|
||||
return null;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
|
||||
if (resident?.isConnected) {
|
||||
return resident;
|
||||
}
|
||||
|
||||
const existing = findBrowserWebview(browserId, ownerDocument);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
|
||||
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
if (!webview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
webview.remove();
|
||||
return;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||
applyResidentWebviewStyle(webview);
|
||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
||||
}
|
||||
|
||||
export function removeResidentBrowserWebview(browserId: string): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
resident?.remove();
|
||||
}
|
||||
|
||||
export function clearResidentBrowserWebviewsForTests(): void {
|
||||
for (const webview of residentWebviewsByBrowserId.values()) {
|
||||
webview.remove();
|
||||
}
|
||||
residentWebviewsByBrowserId.clear();
|
||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||
}
|
||||
@@ -80,6 +80,8 @@ interface CombinedModelSelectorProps {
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
hovered: boolean;
|
||||
pressed: boolean;
|
||||
}) => React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -87,6 +89,15 @@ interface CombinedModelSelectorProps {
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
/**
|
||||
* Render the custom trigger as a full-width form field: the outer Pressable
|
||||
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
||||
* stops painting its own hover/pressed background and rounded corners. The
|
||||
* trigger itself owns the field visuals and reads hovered/pressed to show its
|
||||
* active state. Without this the trigger stays a content-width toolbar chip
|
||||
* (the composer's layout).
|
||||
*/
|
||||
triggerFill?: boolean;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
@@ -574,6 +585,7 @@ export function CombinedModelSelector({
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
triggerFill = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -693,14 +705,26 @@ export function CombinedModelSelector({
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
Boolean(hovered) && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
],
|
||||
[disabled, isOpen, renderTrigger],
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
||||
// hover/pressed state from the args, so the wrapper must not double-paint.
|
||||
if (triggerFill) {
|
||||
return [
|
||||
styles.trigger,
|
||||
styles.customTriggerWrapper,
|
||||
styles.triggerFill,
|
||||
disabled && styles.triggerDisabled,
|
||||
];
|
||||
}
|
||||
return [
|
||||
styles.trigger,
|
||||
Boolean(hovered) && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
];
|
||||
},
|
||||
[disabled, isOpen, renderTrigger, triggerFill],
|
||||
);
|
||||
|
||||
const handleBackToAll = useCallback(() => {
|
||||
@@ -789,12 +813,16 @@ export function CombinedModelSelector({
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: handleTriggerPress,
|
||||
disabled,
|
||||
isOpen,
|
||||
})}
|
||||
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: handleTriggerPress,
|
||||
disabled,
|
||||
isOpen,
|
||||
hovered: Boolean(hovered),
|
||||
pressed,
|
||||
})
|
||||
}
|
||||
</Pressable>
|
||||
) : (
|
||||
<ComboboxTrigger
|
||||
@@ -886,6 +914,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: 0,
|
||||
height: "auto",
|
||||
},
|
||||
// Stretch the wrapper (and, via column + stretch, its single child) to the
|
||||
// full width of the field, with no background or rounding of its own.
|
||||
triggerFill: {
|
||||
alignSelf: "stretch",
|
||||
flexShrink: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: 0,
|
||||
},
|
||||
favoritesContainer: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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,5 +1,15 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
CalendarClock,
|
||||
FolderPlus,
|
||||
History,
|
||||
Home,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -56,6 +66,7 @@ import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
buildSchedulesRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
@@ -105,6 +116,7 @@ interface SidebarLabels {
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
sessions: string;
|
||||
schedules: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
@@ -114,12 +126,14 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
isOpen: boolean;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
handleViewSchedulesNavigate: () => void;
|
||||
}
|
||||
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
isOpen: boolean;
|
||||
handleViewMore: () => void;
|
||||
handleViewSchedules: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
@@ -221,6 +235,10 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
router.push(buildSessionsRoute());
|
||||
}, []);
|
||||
|
||||
const handleViewSchedulesNavigate = useCallback(() => {
|
||||
router.push(buildSchedulesRoute());
|
||||
}, []);
|
||||
|
||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
@@ -231,6 +249,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
switchHost: t("sidebar.host.switchTitle"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
sessions: t("sidebar.sections.sessions"),
|
||||
schedules: t("sidebar.sections.schedules"),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
@@ -267,6 +286,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -282,6 +302,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -557,9 +578,11 @@ function MobileSidebar({
|
||||
isOpen,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
handleViewSchedulesNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -587,6 +610,13 @@ function MobileSidebar({
|
||||
handleViewMoreNavigate();
|
||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||
|
||||
const handleViewSchedules = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewSchedulesNavigate();
|
||||
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
@@ -747,6 +777,14 @@ function MobileSidebar({
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
@@ -827,9 +865,11 @@ function DesktopSidebar({
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
handleViewSchedules,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -909,6 +949,14 @@ function DesktopSidebar({
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @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,11 +13,23 @@ 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", () => {
|
||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
||||
});
|
||||
|
||||
it("falls back to the bot icon for unknown custom providers", () => {
|
||||
|
||||
@@ -6,7 +6,9 @@ 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";
|
||||
@@ -25,7 +27,10 @@ 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,7 +1,10 @@
|
||||
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);
|
||||
@@ -16,6 +19,10 @@ export function ProviderSettingsHost() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === PASEO_AGENT_PROVIDER) {
|
||||
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
|
||||
@@ -92,30 +92,49 @@ function QuestionOptionRow({
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
|
||||
|
||||
// Static left-side control: square for multi-select, circle for single-select.
|
||||
// Always rendered so toggling only swaps fill/border — the row never reflows.
|
||||
const controlStyle = useMemo(
|
||||
() => [
|
||||
styles.selectionControl,
|
||||
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
|
||||
{
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
|
||||
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
|
||||
},
|
||||
],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
|
||||
);
|
||||
const radioDotStyle = useMemo(
|
||||
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
|
||||
[theme.colors.accent],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityRole={multiSelect ? "checkbox" : "radio"}
|
||||
accessibilityLabel={option.label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isSelected}
|
||||
aria-checked={isSelected}
|
||||
>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={controlStyle}>
|
||||
{isSelected && multiSelect ? (
|
||||
<Check size={12} color={theme.colors.accentForeground} />
|
||||
) : null}
|
||||
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
|
||||
</View>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{isSelected ? (
|
||||
<View style={styles.optionCheckSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -124,7 +143,9 @@ function QuestionOptionRow({
|
||||
interface QuestionNavButtonProps {
|
||||
index: number;
|
||||
total: number;
|
||||
header: string;
|
||||
isActive: boolean;
|
||||
isAnswered: boolean;
|
||||
isResponding: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
@@ -132,7 +153,9 @@ interface QuestionNavButtonProps {
|
||||
function QuestionNavButton({
|
||||
index,
|
||||
total,
|
||||
header,
|
||||
isActive,
|
||||
isAnswered,
|
||||
isResponding,
|
||||
onSelect,
|
||||
}: QuestionNavButtonProps) {
|
||||
@@ -171,7 +194,7 @@ function QuestionNavButton({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isActive}
|
||||
@@ -180,11 +203,61 @@ function QuestionNavButton({
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<Text style={textStyle}>{index + 1}</Text>
|
||||
{isAnswered ? (
|
||||
<Check
|
||||
size={12}
|
||||
color={isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{header}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionNavProps {
|
||||
questions: QuestionFormQuestion[];
|
||||
activeIndex: number;
|
||||
isAnswered: (qIndex: number) => boolean;
|
||||
isResponding: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
// Titled tabs (one per question header) with a check on answered ones. Hidden for
|
||||
// a lone question — a single "1 of 1" tab carries no information.
|
||||
function QuestionNav({
|
||||
questions,
|
||||
activeIndex,
|
||||
isAnswered,
|
||||
isResponding,
|
||||
onSelect,
|
||||
}: QuestionNavProps) {
|
||||
if (questions.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={styles.questionNav}
|
||||
testID="question-form-question-nav"
|
||||
accessibilityRole="tablist"
|
||||
>
|
||||
{questions.map((question, qIndex) => (
|
||||
<QuestionNavButton
|
||||
key={question.header}
|
||||
index={qIndex}
|
||||
total={questions.length}
|
||||
header={question.header}
|
||||
isActive={qIndex === activeIndex}
|
||||
isAnswered={isAnswered(qIndex)}
|
||||
isResponding={isResponding}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
accessibilityLabel: string;
|
||||
@@ -355,6 +428,12 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
setActiveQuestionIndex(index);
|
||||
}, []);
|
||||
|
||||
const navIsAnswered = useCallback(
|
||||
(qIndex: number) =>
|
||||
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
|
||||
[questions, selections, otherTexts],
|
||||
);
|
||||
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
if (!isLastQuestion) {
|
||||
if (!activeQuestionAnswered || isResponding) return;
|
||||
@@ -407,9 +486,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const questionNavStyle = useMemo(
|
||||
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||
[isMobile],
|
||||
// Single-select radios need a group; checkboxes are valid standalone.
|
||||
const optionsGroupAccessibility = useMemo(
|
||||
() =>
|
||||
activeQuestion && !activeQuestion.multiSelect
|
||||
? ({
|
||||
accessibilityRole: "radiogroup",
|
||||
accessibilityLabel: activeQuestion.question,
|
||||
} as const)
|
||||
: {},
|
||||
[activeQuestion],
|
||||
);
|
||||
const actionsContainerStyle = useMemo(
|
||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||
@@ -436,33 +522,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID="question-form-card">
|
||||
<View style={styles.questionTopRow}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||
{questions.map((question, qIndex) => {
|
||||
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||
return (
|
||||
<QuestionNavButton
|
||||
key={question.header}
|
||||
index={qIndex}
|
||||
total={questions.length}
|
||||
isActive={isActive}
|
||||
isResponding={isResponding}
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<QuestionNav
|
||||
questions={questions}
|
||||
activeIndex={resolvedActiveQuestionIndex}
|
||||
isAnswered={navIsAnswered}
|
||||
isResponding={isResponding}
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{activeQuestion ? (
|
||||
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||
{activeQuestion.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
<View style={styles.optionsWrap} {...optionsGroupAccessibility}>
|
||||
{activeQuestion.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
@@ -546,12 +622,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionBlock: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
questionTopRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
questionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -571,24 +641,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
questionNav: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNavMobile: {
|
||||
paddingRight: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
questionNavButton: {
|
||||
minWidth: 28,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 999,
|
||||
gap: theme.spacing[1],
|
||||
minHeight: 28,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "700",
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
@@ -603,25 +673,40 @@ const styles = StyleSheet.create((theme) => ({
|
||||
optionItemContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
optionTextBlock: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionCheckSlot: {
|
||||
width: 16,
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
marginTop: 2, // optical-align 18px control to the 22px label first line
|
||||
},
|
||||
selectionControlCheckbox: {
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
selectionControlRadio: {
|
||||
borderRadius: 999,
|
||||
},
|
||||
selectionRadioDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
},
|
||||
otherInput: {
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
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,
|
||||
type IntervalUnit,
|
||||
partsToEveryMs,
|
||||
validateCron,
|
||||
} from "@/utils/schedule-format";
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type CadenceMode = ScheduleCadence["type"];
|
||||
|
||||
interface CronPreset {
|
||||
label: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
|
||||
// through describeCron() so the chip and the live preview agree.
|
||||
const CRON_PRESETS: CronPreset[] = [
|
||||
{ label: "Every hour", expression: "0 * * * *" },
|
||||
{ label: "Daily 9:00", expression: "0 9 * * *" },
|
||||
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
|
||||
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
|
||||
];
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: "every" as const, label: "Interval" },
|
||||
{ value: "cron" as const, label: "Cron" },
|
||||
];
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
{ value: "minutes" as const, label: "Minutes" },
|
||||
{ value: "hours" as const, label: "Hours" },
|
||||
{ value: "days" as const, label: "Days" },
|
||||
];
|
||||
|
||||
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
|
||||
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
|
||||
|
||||
const UNIT_NOUN: Record<IntervalUnit, string> = {
|
||||
minutes: "minute",
|
||||
hours: "hour",
|
||||
days: "day",
|
||||
};
|
||||
|
||||
function describeInterval(value: number, unit: IntervalUnit): string {
|
||||
const noun = UNIT_NOUN[unit];
|
||||
if (value === 1) {
|
||||
return `Runs every ${noun}`;
|
||||
}
|
||||
return `Runs every ${value} ${noun}s`;
|
||||
}
|
||||
|
||||
export interface CadenceEditorProps {
|
||||
value: ScheduleCadence;
|
||||
onChange: (next: ScheduleCadence) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
const mode = value.type;
|
||||
|
||||
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
|
||||
// once from the incoming cadence via lazy state initializers and bump
|
||||
// resetKey only when we change the content ourselves (mode switch, preset
|
||||
// chip) — never on every keystroke.
|
||||
const [intervalValueText, setIntervalValueText] = useState(() =>
|
||||
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
|
||||
);
|
||||
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
|
||||
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
|
||||
);
|
||||
const [cronText, setCronText] = useState(() =>
|
||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||
);
|
||||
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
// Remember the cron expression the user had so toggling Interval -> Cron and
|
||||
// back does not discard a blanked-out field. Interval mode rebuilds straight
|
||||
// from the live numeric value + unit, so it needs no equivalent ref.
|
||||
const lastCronExpression = useRef(
|
||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||
);
|
||||
|
||||
const parsedIntervalValue = useMemo(() => {
|
||||
const parsed = Number.parseInt(intervalValueText, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
}, [intervalValueText]);
|
||||
|
||||
const emitInterval = useCallback(
|
||||
(rawValue: number, unit: IntervalUnit) => {
|
||||
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const emitCron = useCallback(
|
||||
(expression: string) => {
|
||||
lastCronExpression.current = expression;
|
||||
onChange({ type: "cron", expression });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(nextMode: CadenceMode) => {
|
||||
if (nextMode === mode) {
|
||||
return;
|
||||
}
|
||||
if (nextMode === "every") {
|
||||
emitInterval(parsedIntervalValue, intervalUnit);
|
||||
} else {
|
||||
emitCron(cronText.trim() || lastCronExpression.current);
|
||||
}
|
||||
},
|
||||
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
|
||||
);
|
||||
|
||||
const handleIntervalValueChange = useCallback(
|
||||
(text: string) => {
|
||||
// Keep only digits so the cadence stays a positive integer count.
|
||||
const digits = text.replace(/[^0-9]/g, "");
|
||||
setIntervalValueText(digits);
|
||||
const parsed = Number.parseInt(digits, 10);
|
||||
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
|
||||
},
|
||||
[emitInterval, intervalUnit],
|
||||
);
|
||||
|
||||
const handleUnitChange = useCallback(
|
||||
(unit: IntervalUnit) => {
|
||||
setIntervalUnit(unit);
|
||||
emitInterval(parsedIntervalValue, unit);
|
||||
},
|
||||
[emitInterval, parsedIntervalValue],
|
||||
);
|
||||
|
||||
const handleCronChange = useCallback(
|
||||
(text: string) => {
|
||||
setCronText(text);
|
||||
emitCron(text.trim());
|
||||
},
|
||||
[emitCron],
|
||||
);
|
||||
|
||||
const handlePresetPress = useCallback(
|
||||
(expression: string) => {
|
||||
setCronText(expression);
|
||||
bumpFieldResetKey();
|
||||
emitCron(expression);
|
||||
},
|
||||
[emitCron],
|
||||
);
|
||||
|
||||
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
|
||||
const trimmedCron = cronText.trim();
|
||||
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
|
||||
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
|
||||
|
||||
let cronFeedback: ReactNode = null;
|
||||
if (cronError) {
|
||||
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
|
||||
} else if (cronPreview) {
|
||||
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={mode}
|
||||
onValueChange={handleModeChange}
|
||||
options={MODE_OPTIONS}
|
||||
style={styles.modeControl}
|
||||
testID="cadence-mode"
|
||||
/>
|
||||
|
||||
{mode === "every" ? (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.intervalRow}>
|
||||
<AdaptiveTextInput
|
||||
testID="cadence-interval-value"
|
||||
accessibilityLabel="Interval value"
|
||||
initialValue={intervalValueText}
|
||||
resetKey={`cadence-interval-${fieldResetKey}`}
|
||||
value={intervalValueText}
|
||||
onChangeText={handleIntervalValueChange}
|
||||
keyboardType="number-pad"
|
||||
style={styles.intervalInput}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={intervalUnit}
|
||||
onValueChange={handleUnitChange}
|
||||
options={UNIT_OPTIONS}
|
||||
style={styles.unitControl}
|
||||
testID="cadence-interval-unit"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.preview}>{intervalPreview}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.presetRow}>
|
||||
{CRON_PRESETS.map((preset) => (
|
||||
<CronPresetChip
|
||||
key={preset.expression}
|
||||
label={preset.label}
|
||||
expression={preset.expression}
|
||||
isSelected={trimmedCron === preset.expression}
|
||||
onSelect={handlePresetPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<AdaptiveTextInput
|
||||
testID="cadence-cron-expression"
|
||||
accessibilityLabel="Cron expression"
|
||||
initialValue={cronText}
|
||||
resetKey={`cadence-cron-${fieldResetKey}`}
|
||||
value={cronText}
|
||||
onChangeText={handleCronChange}
|
||||
placeholder="0 9 * * *"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
style={styles.cronInput}
|
||||
/>
|
||||
{cronFeedback}
|
||||
<Text style={styles.hint}>Times are in UTC</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CronPresetChip({
|
||||
label,
|
||||
expression,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
expression: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (expression: string) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(expression);
|
||||
}, [onSelect, expression]);
|
||||
const chipStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.chip,
|
||||
isSelected && styles.chipSelected,
|
||||
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
|
||||
],
|
||||
[isSelected],
|
||||
);
|
||||
const labelStyle = useMemo(
|
||||
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
|
||||
[isSelected],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
style={chipStyle}
|
||||
>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
intervalRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
intervalInput: {
|
||||
width: 88,
|
||||
minHeight: 44,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
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.
|
||||
modeControl: {
|
||||
alignSelf: "flex-start",
|
||||
height: 44,
|
||||
},
|
||||
unitControl: {
|
||||
height: 44,
|
||||
},
|
||||
presetRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
chip: {
|
||||
minHeight: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chipHover: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
chipLabelSelected: {
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
cronInput: {
|
||||
minHeight: 44,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: MONOSPACE_FONT,
|
||||
},
|
||||
preview: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hint: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
error: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,867 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, Folder } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
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 { useProjects } from "@/hooks/use-projects";
|
||||
import { validateCron } from "@/utils/schedule-format";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
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;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
mode: "create" | "edit";
|
||||
schedule?: ScheduleSummary;
|
||||
}
|
||||
|
||||
interface ScheduleProjectTarget {
|
||||
optionId: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ScheduleProjectOptions {
|
||||
targets: ScheduleProjectTarget[];
|
||||
options: ComboboxOption[];
|
||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||
}
|
||||
|
||||
// The model/cwd config only exists on new-agent schedules; this screen filters
|
||||
// to that target, but guard anyway so prefill stays type-safe.
|
||||
function newAgentConfig(schedule: ScheduleSummary | undefined) {
|
||||
if (schedule && schedule.target.type === "new-agent") {
|
||||
return schedule.target.config;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialValues | undefined {
|
||||
const config = newAgentConfig(schedule);
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
provider: config.provider as AgentProvider,
|
||||
model: config.model ?? null,
|
||||
modeId: config.modeId ?? null,
|
||||
workingDir: config.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
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)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { targets, options, targetByOptionId };
|
||||
}
|
||||
|
||||
function resolveSelectedScheduleProjectTarget(input: {
|
||||
targets: readonly ScheduleProjectTarget[];
|
||||
serverId: string | null;
|
||||
cwd: string;
|
||||
}): ScheduleProjectTarget | null {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.serverId || !cwd) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
input.targets.find((target) => target.serverId === input.serverId && target.cwd === cwd) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function isSelectedModelValidForProviders(input: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: AgentProvider | null;
|
||||
selectedModel: string;
|
||||
}): boolean {
|
||||
if (!input.selectedProvider) {
|
||||
return false;
|
||||
}
|
||||
const provider = input.providers.find((entry) => entry.id === input.selectedProvider);
|
||||
if (!provider || provider.modelSelection.kind !== "models") {
|
||||
return false;
|
||||
}
|
||||
const selectedModel = input.selectedModel.trim();
|
||||
if (!selectedModel) {
|
||||
return true;
|
||||
}
|
||||
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
|
||||
}
|
||||
|
||||
export function ScheduleFormSheet({
|
||||
serverId,
|
||||
visible,
|
||||
onClose,
|
||||
mode,
|
||||
schedule,
|
||||
}: ScheduleFormSheetProps): ReactElement {
|
||||
const isEdit = mode === "edit";
|
||||
const editConfig = newAgentConfig(schedule);
|
||||
const { projects } = useProjects();
|
||||
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
||||
|
||||
const onlineServerIds = useMemo(
|
||||
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
||||
[projectOptions.targets],
|
||||
);
|
||||
const initialValues = useMemo(
|
||||
() => (isEdit ? buildInitialValues(schedule) : undefined),
|
||||
[isEdit, schedule],
|
||||
);
|
||||
|
||||
// isCreateFlow drives useAgentFormState's RESOLVE pass that applies
|
||||
// initialValues. We want that for edit too (to prefill the picker fields from
|
||||
// the schedule's config), so this stays true in both modes: the form is
|
||||
// always a "fill these fields" flow, seeded either from preferences (create)
|
||||
// or from the schedule (edit).
|
||||
const form = useAgentFormState({
|
||||
initialServerId: serverId ?? null,
|
||||
initialValues,
|
||||
isVisible: visible,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
|
||||
const {
|
||||
selectedServerId,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
selectedMode,
|
||||
selectedThinkingOptionId,
|
||||
workingDir,
|
||||
setProviderAndModelFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
setModeFromUser,
|
||||
setSelectedServerIdFromUser,
|
||||
setWorkingDirFromUser,
|
||||
modeOptions,
|
||||
modelSelectorProviders,
|
||||
isAllModelsLoading,
|
||||
persistFormPreferences,
|
||||
} = form;
|
||||
|
||||
const selectedProjectTarget = useMemo(
|
||||
() =>
|
||||
resolveSelectedScheduleProjectTarget({
|
||||
targets: projectOptions.targets,
|
||||
serverId: selectedServerId,
|
||||
cwd: workingDir,
|
||||
}),
|
||||
[projectOptions.targets, selectedServerId, workingDir],
|
||||
);
|
||||
const selectedProjectOptionId = selectedProjectTarget?.optionId ?? "";
|
||||
const mutationServerId = selectedProjectTarget?.serverId ?? selectedServerId ?? serverId ?? "";
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
(target: ScheduleProjectTarget) => {
|
||||
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
|
||||
clearProviderSelectionFromUser();
|
||||
}
|
||||
setSelectedServerIdFromUser(target.serverId);
|
||||
setWorkingDirFromUser(target.cwd);
|
||||
},
|
||||
[
|
||||
clearProviderSelectionFromUser,
|
||||
selectedProjectTarget,
|
||||
setSelectedServerIdFromUser,
|
||||
setWorkingDirFromUser,
|
||||
],
|
||||
);
|
||||
|
||||
// One nested control selects provider -> model (the draft screen's selector).
|
||||
// Render it as a full-width field that leads with the provider glyph and mutes
|
||||
// its placeholder, matching the working-directory field.
|
||||
const renderModelTrigger = useCallback(
|
||||
({
|
||||
selectedModelLabel,
|
||||
disabled,
|
||||
isOpen,
|
||||
hovered,
|
||||
pressed,
|
||||
}: {
|
||||
selectedModelLabel: string;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
hovered: boolean;
|
||||
pressed: boolean;
|
||||
}): ReactNode => (
|
||||
<ModelTrigger
|
||||
label={selectedModelLabel}
|
||||
provider={selectedProvider}
|
||||
disabled={disabled}
|
||||
active={hovered || pressed || isOpen}
|
||||
isPlaceholder={!selectedModel}
|
||||
/>
|
||||
),
|
||||
[selectedModel, selectedProvider],
|
||||
);
|
||||
|
||||
const { createSchedule, updateSchedule, isCreating, isUpdating } = useScheduleMutations({
|
||||
serverId: mutationServerId,
|
||||
});
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
|
||||
// Name / prompt / cadence / maxRuns are local to this form, not part of
|
||||
// useAgentFormState. Seed once per open from the schedule being edited.
|
||||
const [name, setName] = useState(() => schedule?.name ?? "");
|
||||
const [prompt, setPrompt] = useState(() => schedule?.prompt ?? "");
|
||||
const [maxRuns, setMaxRuns] = useState(() =>
|
||||
schedule?.maxRuns != null ? String(schedule.maxRuns) : "",
|
||||
);
|
||||
const [cadence, setCadence] = useState<ScheduleCadence>(
|
||||
() => schedule?.cadence ?? DEFAULT_CADENCE,
|
||||
);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [fieldResetKey, setFieldResetKey] = useState(0);
|
||||
|
||||
// The sheet stays mounted across opens, so the lazy initializers above only
|
||||
// run once. Re-seed the locally-owned fields (name/prompt/cadence/maxRuns)
|
||||
// each time the sheet transitions closed -> open; the picker fields are
|
||||
// re-seeded by useAgentFormState from initialValues on the same flip.
|
||||
const wasVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (visible && !wasVisibleRef.current) {
|
||||
setName(schedule?.name ?? "");
|
||||
setPrompt(schedule?.prompt ?? "");
|
||||
setMaxRuns(schedule?.maxRuns != null ? String(schedule.maxRuns) : "");
|
||||
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
||||
setSubmitError(null);
|
||||
setFieldResetKey((key) => key + 1);
|
||||
}
|
||||
wasVisibleRef.current = visible;
|
||||
}, [visible, schedule]);
|
||||
|
||||
const promptTrimmed = prompt.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 handleSubmit = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
|
||||
return;
|
||||
}
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await persistFormPreferences();
|
||||
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
|
||||
const maxRunsValue =
|
||||
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
|
||||
|
||||
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));
|
||||
}
|
||||
}, [
|
||||
cadence,
|
||||
createSchedule,
|
||||
isEdit,
|
||||
maxRuns,
|
||||
name,
|
||||
onClose,
|
||||
persistFormPreferences,
|
||||
promptTrimmed,
|
||||
schedule,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProjectTarget,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
updateSchedule,
|
||||
]);
|
||||
|
||||
const handleSubmitPress = useCallback(() => {
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: isEdit ? "Edit schedule" : "New schedule" }),
|
||||
[isEdit],
|
||||
);
|
||||
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
style={styles.footerButton}
|
||||
variant="secondary"
|
||||
onPress={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
style={styles.footerButton}
|
||||
variant="default"
|
||||
onPress={handleSubmitPress}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
testID="schedule-form-submit"
|
||||
>
|
||||
{isEdit ? "Save changes" : "Create schedule"}
|
||||
</Button>
|
||||
</View>
|
||||
),
|
||||
[canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
webScrollbar
|
||||
testID="schedule-form-sheet"
|
||||
>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="schedule-name-input"
|
||||
accessibilityLabel="Schedule name"
|
||||
initialValue={name}
|
||||
resetKey={`schedule-name-${fieldResetKey}`}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Optional"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Prompt</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="schedule-prompt-input"
|
||||
accessibilityLabel="Prompt"
|
||||
initialValue={prompt}
|
||||
resetKey={`schedule-prompt-${fieldResetKey}`}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
placeholder="What should the agent do each run?"
|
||||
style={styles.multilineInput}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Cadence</Text>
|
||||
<CadenceEditor value={cadence} onChange={setCadence} error={cadenceError ?? undefined} />
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Max runs</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="schedule-max-runs-input"
|
||||
accessibilityLabel="Max runs"
|
||||
initialValue={maxRuns}
|
||||
resetKey={`schedule-max-runs-${fieldResetKey}`}
|
||||
value={maxRuns}
|
||||
onChangeText={setMaxRuns}
|
||||
placeholder="Unlimited"
|
||||
style={styles.input}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode field - Combobox over the selected provider's modes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ModeField({
|
||||
options,
|
||||
selectedMode,
|
||||
onSelect,
|
||||
}: {
|
||||
options: { id: string; label: string }[];
|
||||
selectedMode: string;
|
||||
onSelect: (modeId: string) => void;
|
||||
}): ReactElement {
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => options.map((option) => ({ id: option.id, label: option.label })),
|
||||
[options],
|
||||
);
|
||||
|
||||
const selectedLabel =
|
||||
options.find((option) => option.id === selectedMode)?.label ?? "Default mode";
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id);
|
||||
setOpen(false);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setOpen((current) => !current);
|
||||
}, []);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.selectTrigger,
|
||||
(Boolean(hovered) || pressed || open) && styles.selectTriggerActive,
|
||||
],
|
||||
[open],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Mode</Text>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select mode (${selectedLabel})`}
|
||||
testID="schedule-mode-trigger"
|
||||
>
|
||||
<Text style={styles.selectTriggerText} numberOfLines={1}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={styles.chevron.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={selectedMode}
|
||||
onSelect={handleSelect}
|
||||
searchable={comboboxOptions.length > 6}
|
||||
title="Select mode"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectField({
|
||||
options,
|
||||
targetByOptionId,
|
||||
value,
|
||||
selectedTarget,
|
||||
onSelect,
|
||||
}: {
|
||||
options: ComboboxOption[];
|
||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||
value: string;
|
||||
selectedTarget: ScheduleProjectTarget | null;
|
||||
onSelect: (target: ScheduleProjectTarget) => void;
|
||||
}): ReactElement {
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
const target = targetByOptionId.get(id);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
onSelect(target);
|
||||
setOpen(false);
|
||||
},
|
||||
[onSelect, targetByOptionId],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setOpen((current) => !current);
|
||||
}, []);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.selectTrigger,
|
||||
(Boolean(hovered) || pressed || open) && styles.selectTriggerActive,
|
||||
],
|
||||
[open],
|
||||
);
|
||||
|
||||
const displayValue = selectedTarget?.projectName ?? "Select project";
|
||||
const description = selectedTarget
|
||||
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
||||
: null;
|
||||
|
||||
const renderOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<ProjectOptionItem option={option} selected={selected} active={active} onPress={onPress} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select project (${displayValue})`}
|
||||
testID="schedule-project-trigger"
|
||||
>
|
||||
<Text
|
||||
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={styles.chevron.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{description ? <Text style={styles.hint}>{description}</Text> : null}
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onSelect={handleSelect}
|
||||
searchable
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyText="No projects found"
|
||||
title="Select project"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectOptionItem({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}): ReactElement {
|
||||
const leadingSlot = useMemo(
|
||||
() => (
|
||||
<View style={styles.optionIconBox}>
|
||||
<Folder size={16} color={styles.chevron.color} />
|
||||
</View>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={buildProjectOptionTestId(option.id)}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared bits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dynamic provider glyph - reads its color off a StyleSheet object so the
|
||||
* runtime-resolved component stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider);
|
||||
return <Icon size={16} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
// Non-interactive field rendered inside CombinedModelSelector's trigger (with
|
||||
// triggerFill). The selector's outer Pressable owns press/hover; this leaf just
|
||||
// paints the field and reads `active` for the focus border.
|
||||
function ModelTrigger({
|
||||
label,
|
||||
provider,
|
||||
disabled,
|
||||
active,
|
||||
isPlaceholder,
|
||||
}: {
|
||||
label: string;
|
||||
provider: string | null;
|
||||
disabled: boolean;
|
||||
active: boolean;
|
||||
isPlaceholder: boolean;
|
||||
}): ReactElement {
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.selectTrigger,
|
||||
active && styles.selectTriggerActive,
|
||||
disabled && styles.selectTriggerDisabled,
|
||||
],
|
||||
[active, disabled],
|
||||
);
|
||||
return (
|
||||
<View pointerEvents="none" style={containerStyle} testID="schedule-model-trigger">
|
||||
<ProviderGlyph provider={provider} />
|
||||
<Text
|
||||
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={styles.chevron.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
field: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
multilineInput: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
minHeight: 96,
|
||||
},
|
||||
hint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
selectTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
minHeight: 44,
|
||||
},
|
||||
selectTriggerActive: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
selectTriggerDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
selectTriggerText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
selectTriggerPlaceholder: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
optionIconBox: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
footer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerButton: {
|
||||
flex: 1,
|
||||
},
|
||||
// Static color holders read by the dynamic provider icon + chevron (compliant
|
||||
// idiom - no useUnistyles in render).
|
||||
providerIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
chevron: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,339 @@
|
||||
import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { useCallback, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||
// change (never call useUnistyles in render). See docs/unistyles.md.
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedPause = withUnistyles(Pause);
|
||||
const ThemedPlay = withUnistyles(Play);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedKebab = withUnistyles(MoreVertical);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||
|
||||
const MENU_ICON_SIZE = 14;
|
||||
const PROVIDER_ICON_SIZE = 16;
|
||||
|
||||
// Pending flags for each action so the parent table can wire a mutation hook
|
||||
// and the row reflects in-flight state without owning the mutation itself.
|
||||
export interface ScheduleRowPending {
|
||||
pause?: boolean;
|
||||
resume?: boolean;
|
||||
runNow?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleRowActions {
|
||||
onEdit: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onRunNow: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
interface ScheduleRowProps extends ScheduleRowActions {
|
||||
schedule: ScheduleSummary;
|
||||
pending?: ScheduleRowPending;
|
||||
isFirst: boolean;
|
||||
}
|
||||
|
||||
function resolveProvider(schedule: ScheduleSummary): string | null {
|
||||
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
|
||||
}
|
||||
|
||||
function resolveModelLabel(schedule: ScheduleSummary): string {
|
||||
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
|
||||
return schedule.target.config.model;
|
||||
}
|
||||
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 (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) || "—";
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider);
|
||||
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One schedule, rendered as a settings-style card row: provider glyph + title,
|
||||
* a muted secondary line (model · cadence · next run), a StatusBadge, and the
|
||||
* kebab menu that owns every row action. Tapping the row opens the editor.
|
||||
*
|
||||
* Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns
|
||||
* press, the nested kebab Pressable never fights it, and the row background
|
||||
* highlights without reflow.
|
||||
*/
|
||||
export function ScheduleRow({
|
||||
schedule,
|
||||
pending,
|
||||
isFirst,
|
||||
onEdit,
|
||||
onPause,
|
||||
onResume,
|
||||
onRunNow,
|
||||
onDelete,
|
||||
}: ScheduleRowProps): ReactElement {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
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 rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
settingsStyles.row,
|
||||
styles.row,
|
||||
!isFirst && settingsStyles.rowBorder,
|
||||
isHovered && !isCompact && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[isFirst, isHovered, isCompact],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.rowContainer}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={onEdit}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit schedule ${title}`}
|
||||
testID={`schedule-row-${schedule.id}`}
|
||||
>
|
||||
<View style={styles.main}>
|
||||
<View style={styles.leading}>
|
||||
<ProviderGlyph provider={provider} />
|
||||
</View>
|
||||
<View style={styles.textGroup}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.trailing}>
|
||||
<StatusBadge
|
||||
label={statusLabel(schedule.status)}
|
||||
variant={statusVariant(schedule.status)}
|
||||
/>
|
||||
<ScheduleKebabMenu
|
||||
schedule={schedule}
|
||||
pending={pending}
|
||||
onEdit={onEdit}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onRunNow={onRunNow}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const editLeading = <ThemedPencil size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const pauseLeading = <ThemedPause size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
|
||||
return (
|
||||
<ThemedKebab
|
||||
size={MENU_ICON_SIZE}
|
||||
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleKebabMenu({
|
||||
schedule,
|
||||
pending,
|
||||
onEdit,
|
||||
onPause,
|
||||
onResume,
|
||||
onRunNow,
|
||||
onDelete,
|
||||
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabTriggerStyle}
|
||||
accessibilityRole={isNative ? "button" : undefined}
|
||||
accessibilityLabel="Schedule actions"
|
||||
testID={`schedule-kebab-${schedule.id}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
leading={editLeading}
|
||||
onSelect={onEdit}
|
||||
testID={`schedule-menu-edit-${schedule.id}`}
|
||||
>
|
||||
Edit schedule
|
||||
</DropdownMenuItem>
|
||||
{schedule.status === "paused" ? (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
testID={`schedule-menu-resume-${schedule.id}`}
|
||||
>
|
||||
Resume schedule
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed"}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
testID={`schedule-menu-pause-${schedule.id}`}
|
||||
>
|
||||
Pause schedule
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
testID={`schedule-menu-run-${schedule.id}`}
|
||||
>
|
||||
Run now
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
leading={deleteLeading}
|
||||
destructive
|
||||
status={pending?.delete ? "pending" : "idle"}
|
||||
pendingLabel="Deleting..."
|
||||
onSelect={onDelete}
|
||||
testID={`schedule-menu-delete-${schedule.id}`}
|
||||
>
|
||||
Delete schedule
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function kebabTriggerStyle({
|
||||
hovered = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Static color holder for the dynamic provider icon (compliant idiom).
|
||||
providerIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rowContainer: {
|
||||
position: "relative",
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
leading: {
|
||||
width: PROVIDER_ICON_SIZE,
|
||||
height: PROVIDER_ICON_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
textGroup: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
kebabTrigger: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
kebabTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useState, type ReactElement } from "react";
|
||||
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 { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
interface SchedulesTableProps {
|
||||
serverId: string;
|
||||
schedules: ScheduleSummary[];
|
||||
/**
|
||||
* 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
|
||||
* upward rather than mounting a second sheet here.
|
||||
*/
|
||||
onEditSchedule: (schedule: ScheduleSummary) => 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.
|
||||
*/
|
||||
export function SchedulesTable({
|
||||
serverId,
|
||||
schedules,
|
||||
onEditSchedule,
|
||||
}: SchedulesTableProps): ReactElement {
|
||||
const mutations = useScheduleMutations({ serverId });
|
||||
|
||||
return (
|
||||
<View style={styles.listContent} testID="schedules-table">
|
||||
<View style={settingsStyles.card}>
|
||||
{schedules.map((schedule, index) => (
|
||||
<SchedulesTableRow
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
isFirst={index === 0}
|
||||
mutations={mutations}
|
||||
onEditSchedule={onEditSchedule}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
|
||||
|
||||
const NO_PENDING: ScheduleRowPending = {};
|
||||
|
||||
function SchedulesTableRow({
|
||||
schedule,
|
||||
isFirst,
|
||||
mutations,
|
||||
onEditSchedule,
|
||||
}: {
|
||||
schedule: ScheduleSummary;
|
||||
isFirst: boolean;
|
||||
mutations: ScheduleMutations;
|
||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
||||
}): ReactElement {
|
||||
const { id } = schedule;
|
||||
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (key: keyof ScheduleRowPending, action: () => Promise<void>): Promise<void> => {
|
||||
setPending((current) => ({ ...current, [key]: true }));
|
||||
try {
|
||||
await action();
|
||||
} catch {
|
||||
// Mutations roll back their own optimistic cache writes on error and
|
||||
// re-fetch on settle; surfacing per-row toasts here is out of scope.
|
||||
} finally {
|
||||
setPending((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
onEditSchedule(schedule);
|
||||
}, [onEditSchedule, schedule]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
void runAction("pause", () => mutations.pauseSchedule(id));
|
||||
}, [runAction, mutations, id]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
void runAction("resume", () => mutations.resumeSchedule(id));
|
||||
}, [runAction, mutations, id]);
|
||||
|
||||
const handleRunNow = useCallback(() => {
|
||||
void runAction("runNow", () => mutations.runScheduleNow(id));
|
||||
}, [runAction, mutations, id]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Delete schedule",
|
||||
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await runAction("delete", () => mutations.deleteSchedule(id));
|
||||
})();
|
||||
}, [runAction, mutations, id, schedule]);
|
||||
|
||||
return (
|
||||
<ScheduleRow
|
||||
schedule={schedule}
|
||||
isFirst={isFirst}
|
||||
pending={pending}
|
||||
onEdit={handleEdit}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRunNow={handleRunNow}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CONTENT_MAX_WIDTH = 720;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Center the card in a readable column, matching settings and projects.
|
||||
listContent: {
|
||||
width: "100%",
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
@@ -1749,14 +1749,13 @@ function WorkspaceRowItem({
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: WorkspaceRowItemProps) {
|
||||
const currentPathname = usePathname();
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
@@ -334,7 +333,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
}) {
|
||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const currentPathname = usePathname();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||
@@ -342,8 +340,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
if (!workspaceEntry) return null;
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||
images.push(attachment.attachment.screenshot);
|
||||
}
|
||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||
if (workspaceAttachment) {
|
||||
agentAttachments.push(workspaceAttachment);
|
||||
|
||||
@@ -11,6 +11,8 @@ 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,
|
||||
});
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
|
||||
@@ -335,6 +335,15 @@ const CATALOG_DATA = [
|
||||
installLink: "https://stakpak.dev/",
|
||||
command: ["stakpak", "acp"],
|
||||
},
|
||||
{
|
||||
id: "traecli",
|
||||
title: "TRAE CLI",
|
||||
description: "ByteDance's official TRAE coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: "traecli",
|
||||
installLink: "https://docs.trae.cn/cli",
|
||||
command: ["traecli", "acp", "serve"],
|
||||
},
|
||||
{
|
||||
id: "vtcode",
|
||||
title: "VT Code",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getElectronHost } from "@/desktop/electron/host";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "browser.automation.execute.request" }
|
||||
>;
|
||||
type BrowserAutomationExecuteResponse = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "browser.automation.execute.response" }
|
||||
>;
|
||||
|
||||
export type DesktopNotificationPermission = "granted" | "denied" | "default";
|
||||
|
||||
@@ -117,9 +127,24 @@ export interface DesktopBrowserNewTabRequestEvent {
|
||||
}
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
|
||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: 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?: (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
) => Promise<BrowserAutomationExecuteResponse["payload"]>;
|
||||
/** Capture a PNG screenshot of the guest viewport cropped to `rect`. */
|
||||
captureElement?: (
|
||||
browserId: string,
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
) => Promise<string | null>;
|
||||
/** Copy element text and/or an image to the system clipboard from main. */
|
||||
copyElement?: (payload: { text?: string; imageDataUrl?: string }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface DesktopInvokeBridge {
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("ACP provider catalog", () => {
|
||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
|
||||
expect(findProvider("traecli").command).toEqual(["traecli", "acp", "serve"]);
|
||||
});
|
||||
|
||||
it("maps a catalog entry to the daemon provider config patch", () => {
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface UseAgentFormStateResult {
|
||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||
refetchProviderModelsIfStale: () => void;
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||
clearProviderSelectionFromUser: () => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
}
|
||||
@@ -404,6 +405,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
],
|
||||
);
|
||||
|
||||
const clearProviderSelectionFromUser = useCallback(() => {
|
||||
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||
}, []);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||
@@ -547,6 +552,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
refreshProviderModels,
|
||||
refetchProviderModelsIfStale,
|
||||
setProviderAndModelFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
}),
|
||||
@@ -581,6 +587,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
refreshProviderModels,
|
||||
refetchProviderModelsIfStale,
|
||||
setProviderAndModelFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
@@ -136,7 +136,6 @@ function resolveActionShortcutKeys(
|
||||
|
||||
export function useCommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
@@ -223,10 +222,9 @@ export function useCommandCenter() {
|
||||
navigateToAgent({
|
||||
serverId: agent.serverId,
|
||||
agentId: agent.id,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
},
|
||||
[pathname, setOpen],
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker();
|
||||
|
||||
@@ -120,7 +120,7 @@ export function useKeyboardShortcuts({
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||
navigateToWorkspace(action.serverId, action.workspaceId);
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
return navigateToLastWorkspace();
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
@@ -16,6 +16,10 @@ export type {
|
||||
|
||||
export const projectsQueryKey = ["projects"] as const;
|
||||
|
||||
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
|
||||
return hosts.map((host) => host.serverId).join("|");
|
||||
}
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hostErrors: ProjectHostError[];
|
||||
@@ -27,6 +31,11 @@ export interface UseProjectsResult {
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const hostInputs = useMemo<ProjectsHostInput[]>(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
@@ -37,8 +46,9 @@ export function useProjects(): UseProjectsResult {
|
||||
);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
|
||||
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
CreateScheduleOptions,
|
||||
DaemonClient,
|
||||
UpdateScheduleOptions,
|
||||
} 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 { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
|
||||
|
||||
export interface UseScheduleMutationsResult {
|
||||
createSchedule: (input: CreateScheduleInput) => Promise<void>;
|
||||
updateSchedule: (input: UpdateScheduleInput) => Promise<void>;
|
||||
pauseSchedule: (id: string) => Promise<void>;
|
||||
resumeSchedule: (id: string) => Promise<void>;
|
||||
deleteSchedule: (id: string) => Promise<void>;
|
||||
runScheduleNow: (id: string) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
isUpdating: boolean;
|
||||
isPausing: boolean;
|
||||
isResuming: boolean;
|
||||
isDeleting: boolean;
|
||||
isRunningNow: boolean;
|
||||
}
|
||||
|
||||
interface ScheduleListSnapshot {
|
||||
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
|
||||
}
|
||||
|
||||
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
|
||||
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
throw new Error(unavailableMessage);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
|
||||
return {
|
||||
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
|
||||
queryKey: schedulesQueryBaseKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapshot): void {
|
||||
for (const [queryKey, previous] of snapshot.previous) {
|
||||
queryClient.setQueryData(queryKey, previous);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScheduleSections(
|
||||
queryClient: QueryClient,
|
||||
updateSection: (
|
||||
section: FetchAggregatedSchedulesResult["sections"][number],
|
||||
) => FetchAggregatedSchedulesResult["sections"][number],
|
||||
): void {
|
||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||
{ queryKey: schedulesQueryBaseKey },
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return { sections: current.sections.map(updateSection) };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function optimisticallySetStatus(
|
||||
queryClient: QueryClient,
|
||||
serverId: string,
|
||||
id: string,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
export function useScheduleMutations({
|
||||
serverId,
|
||||
}: {
|
||||
serverId: string;
|
||||
}): UseScheduleMutationsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: CreateScheduleInput): Promise<void> => {
|
||||
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
|
||||
const payload = await client.scheduleCreate(input);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
onSettled: invalidate,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (input: UpdateScheduleInput): Promise<void> => {
|
||||
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
|
||||
const payload = await client.scheduleUpdate(input);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
onSettled: invalidate,
|
||||
});
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
|
||||
const payload = await client.schedulePause({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
onMutate: async (id): Promise<ScheduleListSnapshot> => {
|
||||
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
|
||||
const snapshot = snapshotSchedules(queryClient);
|
||||
optimisticallySetStatus(queryClient, serverId, id, "paused");
|
||||
return snapshot;
|
||||
},
|
||||
onError: (_error, _id, context) => {
|
||||
if (context) {
|
||||
restoreSchedules(queryClient, context);
|
||||
}
|
||||
},
|
||||
onSettled: invalidate,
|
||||
});
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
|
||||
const payload = await client.scheduleResume({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
onMutate: async (id): Promise<ScheduleListSnapshot> => {
|
||||
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
|
||||
const snapshot = snapshotSchedules(queryClient);
|
||||
optimisticallySetStatus(queryClient, serverId, id, "active");
|
||||
return snapshot;
|
||||
},
|
||||
onError: (_error, _id, context) => {
|
||||
if (context) {
|
||||
restoreSchedules(queryClient, context);
|
||||
}
|
||||
},
|
||||
onSettled: invalidate,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
|
||||
const payload = await client.scheduleDelete({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
onMutate: async (id): Promise<ScheduleListSnapshot> => {
|
||||
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
|
||||
const snapshot = snapshotSchedules(queryClient);
|
||||
optimisticallyRemove(queryClient, serverId, id);
|
||||
return snapshot;
|
||||
},
|
||||
onError: (_error, _id, context) => {
|
||||
if (context) {
|
||||
restoreSchedules(queryClient, context);
|
||||
}
|
||||
},
|
||||
onSettled: invalidate,
|
||||
});
|
||||
|
||||
const runNowMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
|
||||
const payload = await client.scheduleRunOnce({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
onSettled: invalidate,
|
||||
});
|
||||
|
||||
const createSchedule = useCallback(
|
||||
async (input: CreateScheduleInput): Promise<void> => {
|
||||
await createMutation.mutateAsync(input);
|
||||
},
|
||||
[createMutation],
|
||||
);
|
||||
|
||||
const updateSchedule = useCallback(
|
||||
async (input: UpdateScheduleInput): Promise<void> => {
|
||||
await updateMutation.mutateAsync(input);
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const pauseSchedule = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await pauseMutation.mutateAsync(id);
|
||||
},
|
||||
[pauseMutation],
|
||||
);
|
||||
|
||||
const resumeSchedule = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await resumeMutation.mutateAsync(id);
|
||||
},
|
||||
[resumeMutation],
|
||||
);
|
||||
|
||||
const deleteSchedule = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
const runScheduleNow = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await runNowMutation.mutateAsync(id);
|
||||
},
|
||||
[runNowMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
pauseSchedule,
|
||||
resumeSchedule,
|
||||
deleteSchedule,
|
||||
runScheduleNow,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isPausing: pauseMutation.isPending,
|
||||
isResuming: resumeMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isRunningNow: runNowMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
fetchAggregatedSchedules,
|
||||
type ScheduleHostInput,
|
||||
type ScheduleHostSection,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
|
||||
export type { ScheduleHostSection } 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;
|
||||
}
|
||||
|
||||
export interface UseSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
isRefetching: boolean;
|
||||
}
|
||||
|
||||
export function useSchedules(): UseSchedulesResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return {
|
||||
sections: query.data?.sections ?? [],
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: () => {
|
||||
void query.refetch();
|
||||
},
|
||||
isRefetching: query.isRefetching,
|
||||
};
|
||||
}
|
||||
@@ -423,6 +423,25 @@ export const ar: TranslationResources = {
|
||||
openDevTools: "افتح أدوات تطوير المتصفح",
|
||||
cancelSelector: "إلغاء محدد العنصر",
|
||||
selectElement: "حدد العنصر",
|
||||
grabElement: "نسخ العنصر إلى الحافظة",
|
||||
grabElementLabel: "العنصر",
|
||||
grabFailed: "تعذّر نسخ العنصر",
|
||||
},
|
||||
annotate: {
|
||||
title: "إرسال ملاحظات إلى الوكيل",
|
||||
placeholder: "صف ما الذي ينبغي تغييره…",
|
||||
submit: "إرفاق",
|
||||
cancel: "إلغاء",
|
||||
intents: {
|
||||
fix: "إصلاح",
|
||||
change: "تغيير",
|
||||
question: "سؤال",
|
||||
approve: "موافقة",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "حجم الجهاز",
|
||||
responsive: "متجاوب",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "فشل تحميل الصفحة",
|
||||
@@ -770,6 +789,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "السجل",
|
||||
schedules: "الجداول",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "إعداد البرامج النصية لشجرة العمل",
|
||||
|
||||
@@ -423,6 +423,25 @@ export const en = {
|
||||
openDevTools: "Open browser dev tools",
|
||||
cancelSelector: "Cancel element selector",
|
||||
selectElement: "Select element",
|
||||
grabElement: "Copy element to clipboard",
|
||||
grabElementLabel: "element",
|
||||
grabFailed: "Couldn't copy element",
|
||||
},
|
||||
annotate: {
|
||||
title: "Send feedback to agent",
|
||||
placeholder: "Describe what should change…",
|
||||
submit: "Attach",
|
||||
cancel: "Cancel",
|
||||
intents: {
|
||||
fix: "Fix",
|
||||
change: "Change",
|
||||
question: "Question",
|
||||
approve: "Approve",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Device size",
|
||||
responsive: "Responsive",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "Failed to load page",
|
||||
@@ -777,6 +796,7 @@ export const en = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "History",
|
||||
schedules: "Schedules",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Set up worktree scripts",
|
||||
|
||||
@@ -427,6 +427,25 @@ export const es: TranslationResources = {
|
||||
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",
|
||||
},
|
||||
annotate: {
|
||||
title: "Enviar comentarios al agente",
|
||||
placeholder: "Describe qué debería cambiar…",
|
||||
submit: "Adjuntar",
|
||||
cancel: "Cancelar",
|
||||
intents: {
|
||||
fix: "Corregir",
|
||||
change: "Cambiar",
|
||||
question: "Pregunta",
|
||||
approve: "Aprobar",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Tamaño del dispositivo",
|
||||
responsive: "Adaptable",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "No se pudo cargar la página",
|
||||
@@ -797,6 +816,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "Historial",
|
||||
schedules: "Horarios",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Configurar secuencias de comandos del árbol de trabajo",
|
||||
|
||||
@@ -427,6 +427,25 @@ export const fr: TranslationResources = {
|
||||
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",
|
||||
},
|
||||
annotate: {
|
||||
title: "Envoyer un retour à l'agent",
|
||||
placeholder: "Décrivez ce qui doit changer…",
|
||||
submit: "Joindre",
|
||||
cancel: "Annuler",
|
||||
intents: {
|
||||
fix: "Corriger",
|
||||
change: "Modifier",
|
||||
question: "Question",
|
||||
approve: "Approuver",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Taille de l'appareil",
|
||||
responsive: "Adaptatif",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "Échec du chargement de la page",
|
||||
@@ -796,6 +815,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "Historique",
|
||||
schedules: "Planifications",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Configurer les scripts d'arbre de travail",
|
||||
|
||||
@@ -427,6 +427,25 @@ export const ja: TranslationResources = {
|
||||
openDevTools: "ブラウザ開発ツールを開く",
|
||||
cancelSelector: "要素セレクターをキャンセル",
|
||||
selectElement: "要素を選択",
|
||||
grabElement: "要素をクリップボードにコピー",
|
||||
grabElementLabel: "要素",
|
||||
grabFailed: "要素をコピーできませんでした",
|
||||
},
|
||||
annotate: {
|
||||
title: "エージェントにフィードバックを送信",
|
||||
placeholder: "変更すべき内容を記述…",
|
||||
submit: "添付",
|
||||
cancel: "キャンセル",
|
||||
intents: {
|
||||
fix: "修正",
|
||||
change: "変更",
|
||||
question: "質問",
|
||||
approve: "承認",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "デバイスサイズ",
|
||||
responsive: "レスポンシブ",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "ページの読み込みに失敗しました",
|
||||
@@ -782,6 +801,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "履歴",
|
||||
schedules: "スケジュール",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "ワークツリースクリプトを設定",
|
||||
|
||||
@@ -427,6 +427,25 @@ export const ptBR: TranslationResources = {
|
||||
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",
|
||||
},
|
||||
annotate: {
|
||||
title: "Enviar feedback ao agente",
|
||||
placeholder: "Descreva o que deve mudar…",
|
||||
submit: "Anexar",
|
||||
cancel: "Cancelar",
|
||||
intents: {
|
||||
fix: "Corrigir",
|
||||
change: "Alterar",
|
||||
question: "Pergunta",
|
||||
approve: "Aprovar",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Tamanho do dispositivo",
|
||||
responsive: "Responsivo",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "Falha ao carregar página",
|
||||
@@ -788,6 +807,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "Histórico",
|
||||
schedules: "Agendamentos",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Configurar scripts de worktree",
|
||||
|
||||
@@ -427,6 +427,25 @@ export const ru: TranslationResources = {
|
||||
openDevTools: "Открыть инструменты разработки браузера",
|
||||
cancelSelector: "Отменить выбор элемента",
|
||||
selectElement: "Выберите элемент",
|
||||
grabElement: "Скопировать элемент в буфер обмена",
|
||||
grabElementLabel: "элемент",
|
||||
grabFailed: "Не удалось скопировать элемент",
|
||||
},
|
||||
annotate: {
|
||||
title: "Отправить отзыв агенту",
|
||||
placeholder: "Опишите, что нужно изменить…",
|
||||
submit: "Прикрепить",
|
||||
cancel: "Отмена",
|
||||
intents: {
|
||||
fix: "Исправить",
|
||||
change: "Изменить",
|
||||
question: "Вопрос",
|
||||
approve: "Одобрить",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "Размер устройства",
|
||||
responsive: "Адаптивный",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "Не удалось загрузить страницу",
|
||||
@@ -789,6 +808,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "История",
|
||||
schedules: "Расписания",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "Настройка сценариев рабочего дерева",
|
||||
|
||||
@@ -423,6 +423,25 @@ export const zhCN: TranslationResources = {
|
||||
openDevTools: "打开浏览器开发者工具",
|
||||
cancelSelector: "取消元素选择器",
|
||||
selectElement: "选择元素",
|
||||
grabElement: "复制元素到剪贴板",
|
||||
grabElementLabel: "元素",
|
||||
grabFailed: "复制元素失败",
|
||||
},
|
||||
annotate: {
|
||||
title: "发送反馈给智能体",
|
||||
placeholder: "描述需要修改的内容…",
|
||||
submit: "附加",
|
||||
cancel: "取消",
|
||||
intents: {
|
||||
fix: "修复",
|
||||
change: "修改",
|
||||
question: "提问",
|
||||
approve: "认可",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
label: "设备尺寸",
|
||||
responsive: "自适应",
|
||||
},
|
||||
errors: {
|
||||
failedToLoad: "页面加载失败",
|
||||
@@ -764,6 +783,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "历史",
|
||||
schedules: "计划",
|
||||
},
|
||||
worktreeSetup: {
|
||||
title: "设置 worktree scripts",
|
||||
|
||||
@@ -124,8 +124,8 @@ interface HelpSectionCase {
|
||||
describe("keyboard-shortcuts", () => {
|
||||
const matchingCases: MatchingShortcutCase[] = [
|
||||
{
|
||||
name: "matches Mod+Shift+O to create new agent",
|
||||
event: { key: "O", code: "KeyO", metaKey: true, shiftKey: true },
|
||||
name: "matches Cmd+O to open project",
|
||||
event: { key: "o", code: "KeyO", metaKey: true },
|
||||
context: { isMac: true },
|
||||
action: "agent.new",
|
||||
},
|
||||
@@ -227,8 +227,8 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Ctrl+Shift+O to create new agent on non-mac",
|
||||
event: { key: "O", code: "KeyO", ctrlKey: true, shiftKey: true },
|
||||
name: "matches Ctrl+O to open project on non-mac",
|
||||
event: { key: "o", code: "KeyO", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
action: "agent.new",
|
||||
},
|
||||
@@ -394,6 +394,16 @@ describe("keyboard-shortcuts", () => {
|
||||
name: "does not keep old Alt+Shift+T binding",
|
||||
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
name: "does not keep old Cmd+Shift+O open-project binding after rebind to Cmd+O",
|
||||
event: { key: "O", code: "KeyO", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
},
|
||||
{
|
||||
name: "does not keep old Ctrl+Shift+O open-project binding after rebind to Ctrl+O",
|
||||
event: { key: "O", code: "KeyO", ctrlKey: true, shiftKey: true },
|
||||
context: { isMac: false },
|
||||
},
|
||||
{
|
||||
name: "does not match question-mark shortcut inside editable scopes",
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
@@ -580,7 +590,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
name: "uses web defaults for workspace and tab jump",
|
||||
context: { isMac: true, isDesktop: false },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"new-agent": ["mod", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
||||
@@ -594,7 +604,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
name: "uses desktop defaults for workspace and tab jump",
|
||||
context: { isMac: true, isDesktop: true },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"new-agent": ["mod", "O"],
|
||||
"new-workspace": ["mod", "N"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
|
||||
@@ -121,7 +121,6 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"new-agent": "settings.shortcuts.help.openProject",
|
||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||
"new-worktree": "settings.shortcuts.help.newWorktree",
|
||||
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||
@@ -166,29 +165,33 @@ const SHORTCUT_HELP_NOTE_KEYS: Record<string, string> = {
|
||||
// --- Binding definitions ---
|
||||
|
||||
const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
// --- New agent ---
|
||||
// --- Open project ---
|
||||
// Open project moved from Cmd+Shift+O to Cmd+O. The binding ids intentionally
|
||||
// keep their original "cmd-shift-o" / "ctrl-shift-o" names: user shortcut
|
||||
// overrides are keyed by binding id, so renaming them would silently drop a
|
||||
// user's customized Open project shortcut on upgrade.
|
||||
{
|
||||
id: "agent-new-cmd-shift-o-mac",
|
||||
action: "agent.new",
|
||||
combo: "Cmd+Shift+O",
|
||||
combo: "Cmd+O",
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "projects",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-new-ctrl-shift-o-non-mac",
|
||||
action: "agent.new",
|
||||
combo: "Ctrl+Shift+O",
|
||||
combo: "Ctrl+O",
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "projects",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -218,32 +221,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- New worktree ---
|
||||
{
|
||||
id: "worktree-new-cmd-o-mac",
|
||||
action: "worktree.new",
|
||||
combo: "Cmd+O",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "new-worktree",
|
||||
section: "projects",
|
||||
label: "New worktree",
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "worktree-new-ctrl-o-non-mac",
|
||||
action: "worktree.new",
|
||||
combo: "Ctrl+O",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "new-worktree",
|
||||
section: "projects",
|
||||
label: "New worktree",
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Archive worktree ---
|
||||
{
|
||||
id: "worktree-archive-cmd-shift-backspace-mac",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NavigationContainerRefWithCurrent } from "@react-navigation/native";
|
||||
import {
|
||||
navigateToHostWorkspaceRoute,
|
||||
registerWorkspaceRouteNavigationRef,
|
||||
} from "./workspace-route-navigation";
|
||||
|
||||
function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) {
|
||||
const dispatch = vi.fn();
|
||||
const navigationRef = {
|
||||
current: {
|
||||
isReady: () => options.ready ?? true,
|
||||
getRootState: () => rootState,
|
||||
dispatch,
|
||||
},
|
||||
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>;
|
||||
|
||||
return { navigationRef, dispatch };
|
||||
}
|
||||
|
||||
describe("navigateToHostWorkspaceRoute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registerWorkspaceRouteNavigationRef({
|
||||
current: null,
|
||||
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>)();
|
||||
});
|
||||
|
||||
it("falls back to route navigation when no host route is mounted yet", () => {
|
||||
const { navigationRef, dispatch } = createNavigationRef({
|
||||
key: "root-stack",
|
||||
routeNames: ["index", "settings/[section]", "h/[serverId]"],
|
||||
routes: [{ key: "settings-general", name: "settings/[section]" }],
|
||||
});
|
||||
registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
const dismissTo = vi.fn();
|
||||
|
||||
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("pops to the mounted host route and targets the requested workspace", () => {
|
||||
const { navigationRef, dispatch } = createNavigationRef({
|
||||
key: "root-stack",
|
||||
routeNames: ["index", "settings/[section]", "h/[serverId]"],
|
||||
routes: [
|
||||
{
|
||||
key: "host-server-1",
|
||||
name: "h/[serverId]",
|
||||
params: { serverId: "server-1" },
|
||||
},
|
||||
{ key: "settings-general", name: "settings/[section]" },
|
||||
],
|
||||
});
|
||||
registerWorkspaceRouteNavigationRef(navigationRef);
|
||||
const dismissTo = vi.fn();
|
||||
|
||||
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
|
||||
|
||||
expect(dismissTo).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "POP_TO",
|
||||
target: "root-stack",
|
||||
payload: {
|
||||
name: "h/[serverId]",
|
||||
params: {
|
||||
serverId: "server-1",
|
||||
screen: "workspace/[workspaceId]/index",
|
||||
params: {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
},
|
||||
pop: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
const ROOT_HOST_ROUTE_NAME = "h/[serverId]";
|
||||
const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index";
|
||||
|
||||
interface NavigateToHostWorkspaceRouteDeps {
|
||||
dismissTo(route: string): void;
|
||||
}
|
||||
|
||||
const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps = {
|
||||
dismissTo: (route) => router.dismissTo(route as Href),
|
||||
};
|
||||
|
||||
let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null =
|
||||
null;
|
||||
|
||||
interface NavigateToHostWorkspaceRouteOptions {
|
||||
popToExistingHostRoute?: boolean;
|
||||
}
|
||||
|
||||
export function registerWorkspaceRouteNavigationRef(
|
||||
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
|
||||
): () => void {
|
||||
@@ -26,33 +30,38 @@ export function registerWorkspaceRouteNavigationRef(
|
||||
};
|
||||
}
|
||||
|
||||
function findStackKeyWithRouteName(state: unknown, routeName: string): string | null {
|
||||
function findStackKeyWithMountedRouteName(state: unknown, routeName: string): string | null {
|
||||
if (!state || typeof state !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = state as {
|
||||
key?: unknown;
|
||||
routeNames?: unknown;
|
||||
routes?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof candidate.key === "string" &&
|
||||
Array.isArray(candidate.routeNames) &&
|
||||
candidate.routeNames.includes(routeName)
|
||||
) {
|
||||
return candidate.key;
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidate.routes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof candidate.key === "string" &&
|
||||
candidate.routes.some(
|
||||
(route) =>
|
||||
!!route && typeof route === "object" && (route as { name?: unknown }).name === routeName,
|
||||
)
|
||||
) {
|
||||
return candidate.key;
|
||||
}
|
||||
|
||||
for (const route of candidate.routes) {
|
||||
if (!route || typeof route !== "object") {
|
||||
continue;
|
||||
}
|
||||
const childKey = findStackKeyWithRouteName((route as { state?: unknown }).state, routeName);
|
||||
const childKey = findStackKeyWithMountedRouteName(
|
||||
(route as { state?: unknown }).state,
|
||||
routeName,
|
||||
);
|
||||
if (childKey) {
|
||||
return childKey;
|
||||
}
|
||||
@@ -69,7 +78,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
||||
}
|
||||
|
||||
const rootState = navigation.getRootState();
|
||||
const target = findStackKeyWithRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
||||
const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
@@ -99,11 +108,11 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
||||
|
||||
export function navigateToHostWorkspaceRoute(
|
||||
route: string,
|
||||
options: NavigateToHostWorkspaceRouteOptions = {},
|
||||
deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps,
|
||||
): void {
|
||||
if (options.popToExistingHostRoute && dispatchHostWorkspacePopTo(route)) {
|
||||
if (dispatchHostWorkspacePopTo(route)) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.dismissTo(route as Href);
|
||||
deps.dismissTo(route);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export type AgentFormAction =
|
||||
modelId: string;
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
}
|
||||
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
|
||||
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||
| { type: "SET_WORKING_DIR"; value: string }
|
||||
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
|
||||
@@ -565,6 +566,24 @@ export function resolveAgentForm(
|
||||
};
|
||||
}
|
||||
|
||||
case "CLEAR_PROVIDER_SELECTION_FROM_USER":
|
||||
return {
|
||||
form: {
|
||||
...state.form,
|
||||
provider: null,
|
||||
model: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
},
|
||||
userModified: {
|
||||
...state.userModified,
|
||||
provider: true,
|
||||
model: true,
|
||||
modeId: true,
|
||||
thinkingOptionId: true,
|
||||
},
|
||||
};
|
||||
|
||||
case "SET_THINKING_OPTION_FROM_USER":
|
||||
return {
|
||||
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true });
|
||||
});
|
||||
import type {
|
||||
DaemonClient,
|
||||
ConnectionState,
|
||||
@@ -16,6 +20,10 @@ import {
|
||||
type HostRuntimeStorage,
|
||||
} from "./host-runtime";
|
||||
|
||||
vi.mock("@/browser-automation/handler", () => ({
|
||||
mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
@@ -342,6 +350,18 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean):
|
||||
});
|
||||
}
|
||||
|
||||
class BrowserClientLifecycle {
|
||||
public active: Array<{ serverId: string; connectionId: string }> = [];
|
||||
|
||||
mount(input: { host: HostProfile; connection: HostConnection }): () => void {
|
||||
const entry = { serverId: input.host.serverId, connectionId: input.connection.id };
|
||||
this.active.push(entry);
|
||||
return () => {
|
||||
this.active = this.active.filter((current) => current !== entry);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("HostRuntimeController", () => {
|
||||
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
||||
const oldRelay: HostConnection = {
|
||||
@@ -458,6 +478,39 @@ describe("HostRuntimeController", () => {
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
});
|
||||
|
||||
it("keeps browser client lifecycle tied to the active host runtime client", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const fakeClient = makeConnectedProbeClient(12);
|
||||
const lifecycle = new BrowserClientLifecycle();
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: hostProfile, connection }) => ({
|
||||
client: makeConnectedProbeClient(10) as unknown as DaemonClient,
|
||||
serverId: hostProfile.serverId,
|
||||
hostname: connection.id,
|
||||
}),
|
||||
getClientId: async () => "cid_runtime_stable",
|
||||
mountClientHandlers: (input) => lifecycle.mount(input),
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start({
|
||||
autoProbe: false,
|
||||
initialConnection: {
|
||||
connectionId: "direct:lan:6767",
|
||||
existingClient: fakeClient as unknown as DaemonClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(lifecycle.active).toEqual([{ serverId: "srv_test", connectionId: "direct:lan:6767" }]);
|
||||
|
||||
await controller.stop();
|
||||
|
||||
expect(lifecycle.active).toEqual([]);
|
||||
});
|
||||
|
||||
it("adopts the first successful probe on startup", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
@@ -1431,7 +1484,7 @@ describe("HostRuntimeStore", () => {
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "agent-recent",
|
||||
cwd: "/Users/moboudra/dev/paseo",
|
||||
cwd: "/workspaces/paseo",
|
||||
updatedAt: "2026-03-04T12:00:00.000Z",
|
||||
title: "Recent agent",
|
||||
}),
|
||||
@@ -1444,7 +1497,7 @@ describe("HostRuntimeStore", () => {
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "agent-stale-attention",
|
||||
cwd: "/Users/moboudra/dev/paseo-pr67-review",
|
||||
cwd: "/workspaces/paseo-pr67-review",
|
||||
updatedAt: "2026-02-20T08:00:00.000Z",
|
||||
title: "Needs triage",
|
||||
requiresAttention: true,
|
||||
@@ -1612,7 +1665,7 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().setAgents(host.serverId, () => {
|
||||
const stale = makeFetchAgentsEntry({
|
||||
id: "agent-archived",
|
||||
cwd: "/Users/moboudra/dev/paseo",
|
||||
cwd: "/workspaces/paseo",
|
||||
updatedAt: "2026-03-30T15:29:00.000Z",
|
||||
archivedAt: null,
|
||||
title: "Stale active copy",
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
} from "@/workspace/legacy-daemon-workspaces";
|
||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
@@ -138,6 +141,11 @@ export interface HostRuntimeControllerDeps {
|
||||
}>;
|
||||
getClientId: () => Promise<string>;
|
||||
readInitialConnectionHint?: () => InitialDaemonConnectionHint | null;
|
||||
mountClientHandlers?: (input: {
|
||||
client: DaemonClient;
|
||||
host: HostProfile;
|
||||
connection: HostConnection;
|
||||
}) => () => void;
|
||||
}
|
||||
|
||||
export interface HostRuntimeStorage {
|
||||
@@ -514,6 +522,12 @@ function probeIntervalForConnection(
|
||||
}
|
||||
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
const desktopBrowserAutomationAvailable =
|
||||
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
|
||||
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
|
||||
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
@@ -523,6 +537,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
|
||||
};
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
@@ -560,8 +575,15 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
connectToDaemon(connection, {
|
||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
|
||||
}),
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
mountClientHandlers: ({ client, host }) => {
|
||||
if (!browserAutomationCapabilities) {
|
||||
return () => {};
|
||||
}
|
||||
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -574,6 +596,7 @@ export class HostRuntimeController {
|
||||
private listeners = new Set<() => void>();
|
||||
private activeClient: DaemonClient | null = null;
|
||||
private unsubscribeClientStatus: (() => void) | null = null;
|
||||
private unsubscribeClientHandlers: (() => void) | null = null;
|
||||
private probeIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private started = false;
|
||||
private connectionFirstSeenAt = new Map<string, number>();
|
||||
@@ -656,6 +679,10 @@ export class HostRuntimeController {
|
||||
this.unsubscribeClientStatus();
|
||||
this.unsubscribeClientStatus = null;
|
||||
}
|
||||
if (this.unsubscribeClientHandlers) {
|
||||
this.unsubscribeClientHandlers();
|
||||
this.unsubscribeClientHandlers = null;
|
||||
}
|
||||
if (this.activeClient) {
|
||||
const prev = this.activeClient;
|
||||
this.activeClient = null;
|
||||
@@ -1133,6 +1160,10 @@ export class HostRuntimeController {
|
||||
this.unsubscribeClientStatus();
|
||||
this.unsubscribeClientStatus = null;
|
||||
}
|
||||
if (this.unsubscribeClientHandlers) {
|
||||
this.unsubscribeClientHandlers();
|
||||
this.unsubscribeClientHandlers = null;
|
||||
}
|
||||
if (this.activeClient) {
|
||||
const previousClient = this.activeClient;
|
||||
this.activeClient = null;
|
||||
@@ -1206,6 +1237,8 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
this.activeClient = client;
|
||||
this.unsubscribeClientHandlers =
|
||||
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
|
||||
this.applyConnectionEvent({
|
||||
type: "select_connection",
|
||||
connectionId: connection.id,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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 interface ScheduleHostInput {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export interface ScheduleRuntimeSnapshot {
|
||||
connectionStatus: string;
|
||||
}
|
||||
|
||||
export interface ScheduleRuntime {
|
||||
getClient(serverId: string): Pick<DaemonClient, "scheduleList"> | null;
|
||||
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
||||
}
|
||||
|
||||
export interface ScheduleHostSection {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
schedules: ScheduleSummary[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
}
|
||||
|
||||
export async function fetchAggregatedSchedules(
|
||||
input: FetchAggregatedSchedulesInput,
|
||||
): Promise<FetchAggregatedSchedulesResult> {
|
||||
const sections = await Promise.all(
|
||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { sections };
|
||||
}
|
||||
@@ -56,12 +56,9 @@ import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
filterWorkspaceProjectsForHost,
|
||||
getHostProjectSourceDirectory,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveInitialWorkspaceProject,
|
||||
resolveSelectedHostProject,
|
||||
useHostProjects,
|
||||
type HostProjectListItem,
|
||||
} from "@/projects/host-projects";
|
||||
@@ -88,6 +85,7 @@ import {
|
||||
resolveNewWorkspaceAutomaticServerId,
|
||||
resolveNewWorkspaceInitialServerId,
|
||||
} from "./new-workspace-initial-context";
|
||||
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
|
||||
|
||||
function resolveCheckoutRequest(
|
||||
selectedItem: PickerItem | null,
|
||||
@@ -168,7 +166,6 @@ interface PickerSelection {
|
||||
|
||||
const BRANCH_OPTION_PREFIX = "branch:";
|
||||
const PR_OPTION_PREFIX = "github-pr:";
|
||||
const PROJECT_OPTION_PREFIX = "project:";
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
|
||||
// this so toggling Isolation to Local hides the row without shifting the form.
|
||||
@@ -510,20 +507,6 @@ function prOptionId(number: number): string {
|
||||
return `${PR_OPTION_PREFIX}${number}`;
|
||||
}
|
||||
|
||||
function projectOptionId(projectId: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
|
||||
const projectByOptionId = new Map<string, HostProjectListItem>();
|
||||
const options = projects.map((project) => {
|
||||
const id = projectOptionId(project.projectKey);
|
||||
projectByOptionId.set(id, project);
|
||||
return { id, label: project.projectName };
|
||||
});
|
||||
return { options, projectByOptionId };
|
||||
}
|
||||
|
||||
function NewWorkspacePickerOption({
|
||||
option,
|
||||
selected,
|
||||
@@ -1164,7 +1147,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
currentPathname: "/new",
|
||||
target: submission.target,
|
||||
});
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||
@@ -1287,7 +1269,6 @@ interface NewWorkspaceInitialContextState {
|
||||
projects: HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
routeDisplayName: string;
|
||||
}
|
||||
|
||||
function useNewWorkspaceInitialContext({
|
||||
@@ -1354,112 +1335,6 @@ function useNewWorkspaceInitialContext({
|
||||
projects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
routeDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
interface NewWorkspaceProjectPickerInput {
|
||||
selectedServerId: string;
|
||||
projects: HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
displayName?: string;
|
||||
allowAllProjects: boolean;
|
||||
}
|
||||
|
||||
interface NewWorkspaceProjectPickerState {
|
||||
projects: HostProjectListItem[];
|
||||
selectedProject: HostProjectListItem | null;
|
||||
selectedSourceDirectory: string | null;
|
||||
selectedDisplayName: string;
|
||||
projectPickerOptions: Array<{ id: string; label: string }>;
|
||||
projectByOptionId: Map<string, HostProjectListItem>;
|
||||
selectedProjectOptionId: string;
|
||||
projectTriggerLabel: string;
|
||||
handleSelectProjectOption: (id: string) => void;
|
||||
}
|
||||
|
||||
function useNewWorkspaceProjectPicker({
|
||||
selectedServerId,
|
||||
projects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
displayName: displayNameProp,
|
||||
allowAllProjects,
|
||||
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
||||
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
const selectableProjects = useMemo(
|
||||
() =>
|
||||
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
|
||||
[allowAllProjects, projects, selectedServerId],
|
||||
);
|
||||
const initialProject = useMemo(
|
||||
() =>
|
||||
resolveInitialWorkspaceProject({
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
projects: selectableProjects,
|
||||
serverId: selectedServerId,
|
||||
allowAllProjects,
|
||||
}),
|
||||
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
|
||||
);
|
||||
|
||||
const routeProjectKey = routeProject?.projectKey ?? null;
|
||||
useEffect(() => {
|
||||
setManualProjectKey(null);
|
||||
}, [routeProjectKey]);
|
||||
|
||||
const selectedProjectKey = useMemo(() => {
|
||||
if (manualProjectKey) {
|
||||
const manual = resolveSelectedHostProject({
|
||||
selectedProjectKey: manualProjectKey,
|
||||
projects: selectableProjects,
|
||||
routeProject: null,
|
||||
lastActiveProject: null,
|
||||
});
|
||||
if (manual) return manual.projectKey;
|
||||
}
|
||||
return initialProject?.projectKey ?? null;
|
||||
}, [initialProject, manualProjectKey, selectableProjects]);
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
resolveSelectedHostProject({
|
||||
selectedProjectKey,
|
||||
projects: selectableProjects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
}),
|
||||
[lastActiveProject, routeProject, selectableProjects, selectedProjectKey],
|
||||
);
|
||||
const { options: projectPickerOptions, projectByOptionId } = useMemo(
|
||||
() => computeProjectOptionData(selectableProjects),
|
||||
[selectableProjects],
|
||||
);
|
||||
const handleSelectProjectOption = useCallback(
|
||||
(id: string) => {
|
||||
const project = projectByOptionId.get(id);
|
||||
if (!project) return;
|
||||
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
|
||||
setManualProjectKey(project.projectKey);
|
||||
},
|
||||
[allowAllProjects, projectByOptionId],
|
||||
);
|
||||
|
||||
return {
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSourceDirectory: selectedProject
|
||||
? getHostProjectSourceDirectory(selectedProject, selectedServerId)
|
||||
: null,
|
||||
selectedDisplayName: selectedProject?.projectName ?? displayName,
|
||||
projectPickerOptions,
|
||||
projectByOptionId,
|
||||
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
|
||||
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
|
||||
handleSelectProjectOption,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1698,7 +1573,6 @@ export function NewWorkspaceScreen({
|
||||
projects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
routeDisplayName,
|
||||
} = useNewWorkspaceInitialContext({
|
||||
serverId,
|
||||
sourceDirectory: sourceDirectoryProp,
|
||||
@@ -1747,7 +1621,6 @@ export function NewWorkspaceScreen({
|
||||
projects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
displayName: routeDisplayName,
|
||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||
});
|
||||
|
||||
@@ -2120,7 +1993,7 @@ export function NewWorkspaceScreen({
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId) =>
|
||||
navigateToWorkspace(targetServerId, workspaceId, { currentPathname: "/new" }),
|
||||
navigateToWorkspace(targetServerId, workspaceId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
|
||||
import { isWorkspaceArchivePending } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
filterWorkspaceProjectsForHost,
|
||||
getHostProjectSourceDirectory,
|
||||
resolveInitialWorkspaceProject,
|
||||
type HostProjectListItem,
|
||||
} from "@/projects/host-projects";
|
||||
import {
|
||||
createManualProjectSelectionContextKey,
|
||||
createProjectSelectionContextKey,
|
||||
createProjectSelection,
|
||||
reconcileProjectSelection,
|
||||
resolveInitialProjectSelectionSource,
|
||||
resolveProjectSelection,
|
||||
type ProjectSelection,
|
||||
type ProjectSelectionContext,
|
||||
} from "./project-selection";
|
||||
|
||||
const PROJECT_OPTION_PREFIX = "project:";
|
||||
|
||||
interface NewWorkspaceProjectPickerInput {
|
||||
selectedServerId: string;
|
||||
projects: HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
allowAllProjects: boolean;
|
||||
}
|
||||
|
||||
interface NewWorkspaceProjectPickerState {
|
||||
selectedProject: HostProjectListItem | null;
|
||||
selectedSourceDirectory: string | null;
|
||||
projectPickerOptions: ComboboxOptionType[];
|
||||
projectByOptionId: Map<string, HostProjectListItem>;
|
||||
selectedProjectOptionId: string;
|
||||
projectTriggerLabel: string;
|
||||
handleSelectProjectOption: (id: string) => void;
|
||||
}
|
||||
|
||||
function projectOptionId(projectId: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
|
||||
const projectByOptionId = new Map<string, HostProjectListItem>();
|
||||
const options = projects.map((project) => {
|
||||
const id = projectOptionId(project.projectKey);
|
||||
projectByOptionId.set(id, project);
|
||||
return { id, label: project.projectName };
|
||||
});
|
||||
return { options, projectByOptionId };
|
||||
}
|
||||
|
||||
function resolveWorkspaceIdFromProjectWorkspaceKey(input: {
|
||||
selectedServerId: string;
|
||||
workspaceKey: string;
|
||||
}): string | null {
|
||||
const prefix = `${input.selectedServerId}:`;
|
||||
return input.workspaceKey.startsWith(prefix) ? input.workspaceKey.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
function hasPendingArchiveForProject(input: {
|
||||
selectedServerId: string;
|
||||
project: HostProjectListItem;
|
||||
}): boolean {
|
||||
for (const workspaceKey of input.project.workspaceKeys) {
|
||||
const workspaceId = resolveWorkspaceIdFromProjectWorkspaceKey({
|
||||
selectedServerId: input.selectedServerId,
|
||||
workspaceKey,
|
||||
});
|
||||
if (
|
||||
workspaceId &&
|
||||
isWorkspaceArchivePending({ serverId: input.selectedServerId, workspaceId })
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.selectedServerId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNewWorkspaceProjectPicker({
|
||||
selectedServerId,
|
||||
projects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
allowAllProjects,
|
||||
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
||||
const selectableProjects = useMemo(
|
||||
() =>
|
||||
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
|
||||
[allowAllProjects, projects, selectedServerId],
|
||||
);
|
||||
const initialProject = useMemo(
|
||||
() =>
|
||||
resolveInitialWorkspaceProject({
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
projects: selectableProjects,
|
||||
serverId: selectedServerId,
|
||||
allowAllProjects,
|
||||
}),
|
||||
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
|
||||
);
|
||||
|
||||
const routeProjectKey = routeProject?.projectKey ?? null;
|
||||
const selectionContextKey = createProjectSelectionContextKey({
|
||||
selectedServerId,
|
||||
routeProjectKey,
|
||||
allowAllProjects,
|
||||
});
|
||||
const manualSelectionContextKey = createManualProjectSelectionContextKey({
|
||||
selectedServerId,
|
||||
routeProjectKey,
|
||||
});
|
||||
const shouldPreserveMissingProject = useCallback(
|
||||
(project: HostProjectListItem) =>
|
||||
hasPendingArchiveForProject({
|
||||
selectedServerId,
|
||||
project,
|
||||
}),
|
||||
[selectedServerId],
|
||||
);
|
||||
const selectionContext = useMemo<ProjectSelectionContext>(
|
||||
() => ({
|
||||
contextKey: selectionContextKey,
|
||||
manualContextKey: manualSelectionContextKey,
|
||||
initialProject,
|
||||
initialProjectSource: resolveInitialProjectSelectionSource({
|
||||
initialProject,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
}),
|
||||
projects: selectableProjects,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
shouldPreserveMissingProject,
|
||||
}),
|
||||
[
|
||||
initialProject,
|
||||
lastActiveProject,
|
||||
manualSelectionContextKey,
|
||||
routeProject,
|
||||
selectableProjects,
|
||||
selectionContextKey,
|
||||
shouldPreserveMissingProject,
|
||||
],
|
||||
);
|
||||
const [projectSelection, setProjectSelection] = useState<ProjectSelection>(() =>
|
||||
createProjectSelection(selectionContext),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setProjectSelection((current) => reconcileProjectSelection(current, selectionContext));
|
||||
}, [selectionContext]);
|
||||
|
||||
const activeSelection = reconcileProjectSelection(projectSelection, selectionContext);
|
||||
const selectedProject = resolveProjectSelection(activeSelection, selectionContext);
|
||||
const { options: projectPickerOptions, projectByOptionId } = useMemo(
|
||||
() => computeProjectOptionData(selectableProjects),
|
||||
[selectableProjects],
|
||||
);
|
||||
const handleSelectProjectOption = useCallback(
|
||||
(id: string) => {
|
||||
const project = projectByOptionId.get(id);
|
||||
if (!project) return;
|
||||
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
|
||||
setProjectSelection({
|
||||
contextKey: manualSelectionContextKey,
|
||||
projectKey: project.projectKey,
|
||||
project,
|
||||
source: "manual",
|
||||
});
|
||||
},
|
||||
[allowAllProjects, manualSelectionContextKey, projectByOptionId],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedProject,
|
||||
selectedSourceDirectory: selectedProject
|
||||
? getHostProjectSourceDirectory(selectedProject, selectedServerId)
|
||||
: null,
|
||||
projectPickerOptions,
|
||||
projectByOptionId,
|
||||
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
|
||||
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
|
||||
handleSelectProjectOption,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
import {
|
||||
createManualProjectSelectionContextKey,
|
||||
createProjectSelectionContextKey,
|
||||
createProjectSelection,
|
||||
reconcileProjectSelection,
|
||||
resolveInitialProjectSelectionSource,
|
||||
resolveProjectSelection,
|
||||
type ProjectSelection,
|
||||
type ProjectSelectionContext,
|
||||
} from "./project-selection";
|
||||
|
||||
function project(projectKey: string, serverId = "host"): HostProjectListItem {
|
||||
return {
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${projectKey}`,
|
||||
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
function context(
|
||||
input: Partial<ProjectSelectionContext> & {
|
||||
initialProject: HostProjectListItem | null;
|
||||
projects: HostProjectListItem[];
|
||||
},
|
||||
): ProjectSelectionContext {
|
||||
const contextKey = input.contextKey ?? "host:";
|
||||
const routeProject = input.routeProject ?? null;
|
||||
const lastActiveProject = input.lastActiveProject ?? null;
|
||||
return {
|
||||
contextKey,
|
||||
manualContextKey: input.manualContextKey ?? contextKey,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
initialProjectSource:
|
||||
input.initialProjectSource ??
|
||||
resolveInitialProjectSelectionSource({
|
||||
initialProject: input.initialProject,
|
||||
routeProject,
|
||||
lastActiveProject,
|
||||
}),
|
||||
shouldPreserveMissingProject: () => false,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reconcileProjectSelection", () => {
|
||||
it("keeps a still-selectable project when the default moves after archive", () => {
|
||||
const remembered = project("remembered");
|
||||
const other = project("other");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: remembered, projects: [remembered, other] }),
|
||||
);
|
||||
const afterArchive = context({
|
||||
initialProject: other,
|
||||
projects: [other, remembered],
|
||||
});
|
||||
|
||||
const reconciled = reconcileProjectSelection(current, afterArchive);
|
||||
|
||||
expect(reconciled).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: remembered.projectKey,
|
||||
project: remembered,
|
||||
source: "initial",
|
||||
});
|
||||
expect(resolveProjectSelection(reconciled, afterArchive)).toEqual(remembered);
|
||||
});
|
||||
|
||||
it("resets stale selection when the route project context changes", () => {
|
||||
const manual = project("manual");
|
||||
const routeProject = project("route-project");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "host:previous-route",
|
||||
projectKey: manual.projectKey,
|
||||
project: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const nextContext = context({
|
||||
contextKey: "host:route-project",
|
||||
initialProject: routeProject,
|
||||
projects: [manual, routeProject],
|
||||
routeProject,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, nextContext)).toEqual({
|
||||
contextKey: "host:route-project",
|
||||
projectKey: routeProject.projectKey,
|
||||
project: routeProject,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates an empty initial selection when projects arrive", () => {
|
||||
const initialProject = project("hydrated");
|
||||
const current = createProjectSelection(context({ initialProject: null, projects: [] }));
|
||||
const hydratedContext = context({
|
||||
initialProject,
|
||||
projects: [initialProject],
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: initialProject.projectKey,
|
||||
project: initialProject,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores hydrated project snapshots before archive gaps", () => {
|
||||
const routeProject = project("route-project");
|
||||
const hydratedProject: HostProjectListItem = {
|
||||
...routeProject,
|
||||
workspaceKeys: ["host:workspace"],
|
||||
};
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: routeProject, projects: [], routeProject }),
|
||||
);
|
||||
const afterHydration = context({
|
||||
initialProject: hydratedProject,
|
||||
projects: [hydratedProject],
|
||||
routeProject,
|
||||
});
|
||||
|
||||
const hydratedSelection = reconcileProjectSelection(current, afterHydration);
|
||||
|
||||
expect(hydratedSelection).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: hydratedProject.projectKey,
|
||||
project: hydratedProject,
|
||||
source: "initial",
|
||||
});
|
||||
|
||||
const archiveGap = context({
|
||||
initialProject: routeProject,
|
||||
projects: [],
|
||||
routeProject,
|
||||
shouldPreserveMissingProject: (candidate) =>
|
||||
candidate.workspaceKeys.includes("host:workspace"),
|
||||
});
|
||||
|
||||
expect(resolveProjectSelection(hydratedSelection, archiveGap)).toEqual(hydratedProject);
|
||||
});
|
||||
|
||||
it("resets an automatic fallback when the remembered project hydrates", () => {
|
||||
const fallback = project("fallback");
|
||||
const remembered = project("remembered");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: fallback, projects: [fallback, remembered] }),
|
||||
);
|
||||
const afterRememberedHydration = context({
|
||||
initialProject: remembered,
|
||||
projects: [fallback, remembered],
|
||||
lastActiveProject: remembered,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: remembered.projectKey,
|
||||
project: remembered,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps manual selections when the remembered project hydrates", () => {
|
||||
const manual = project("manual");
|
||||
const remembered = project("remembered");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "host:",
|
||||
projectKey: manual.projectKey,
|
||||
project: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const afterRememberedHydration = context({
|
||||
initialProject: remembered,
|
||||
projects: [manual, remembered],
|
||||
lastActiveProject: remembered,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
|
||||
});
|
||||
|
||||
it("resets fallback selection when host project capability changes", () => {
|
||||
const fallback = project("git-fallback");
|
||||
const remembered = project("remembered-directory");
|
||||
const current = createProjectSelection(
|
||||
context({
|
||||
contextKey: createProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
allowAllProjects: false,
|
||||
}),
|
||||
initialProject: fallback,
|
||||
projects: [fallback, remembered],
|
||||
}),
|
||||
);
|
||||
const afterCapabilityHydration = context({
|
||||
contextKey: createProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
allowAllProjects: true,
|
||||
}),
|
||||
initialProject: remembered,
|
||||
projects: [fallback, remembered],
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({
|
||||
contextKey: "host:all-projects:",
|
||||
projectKey: remembered.projectKey,
|
||||
project: remembered,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a still-selectable manual selection when host project capability changes", () => {
|
||||
const fallback = project("git-fallback");
|
||||
const manual = project("manual-choice");
|
||||
const remembered = project("remembered-directory");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: createManualProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
}),
|
||||
projectKey: manual.projectKey,
|
||||
project: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const afterCapabilityHydration = context({
|
||||
contextKey: createProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
allowAllProjects: true,
|
||||
}),
|
||||
manualContextKey: createManualProjectSelectionContextKey({
|
||||
selectedServerId: "host",
|
||||
routeProjectKey: null,
|
||||
}),
|
||||
initialProject: remembered,
|
||||
projects: [fallback, manual, remembered],
|
||||
});
|
||||
|
||||
const reconciled = reconcileProjectSelection(current, afterCapabilityHydration);
|
||||
|
||||
expect(reconciled).toEqual(current);
|
||||
expect(resolveProjectSelection(reconciled, afterCapabilityHydration)).toEqual(manual);
|
||||
});
|
||||
|
||||
it("keeps the selected project snapshot during a pending archive gap", () => {
|
||||
const remembered = project("remembered");
|
||||
const fallback = project("fallback");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: remembered, projects: [remembered] }),
|
||||
);
|
||||
const withoutRemembered = context({
|
||||
initialProject: fallback,
|
||||
projects: [fallback],
|
||||
shouldPreserveMissingProject: (candidate) => candidate.projectKey === remembered.projectKey,
|
||||
});
|
||||
|
||||
const reconciled = reconcileProjectSelection(current, withoutRemembered);
|
||||
|
||||
expect(reconciled).toEqual(current);
|
||||
expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered);
|
||||
});
|
||||
|
||||
it("falls back when the selected project disappears without a pending archive", () => {
|
||||
const remembered = project("remembered");
|
||||
const fallback = project("fallback");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: remembered, projects: [remembered] }),
|
||||
);
|
||||
const withoutRemembered = context({
|
||||
initialProject: fallback,
|
||||
projects: [fallback],
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: fallback.projectKey,
|
||||
project: fallback,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves manual selections from selectable projects, not route or remembered projects", () => {
|
||||
const manual = project("manual");
|
||||
const routeProject = project("route-project");
|
||||
const remembered = project("remembered");
|
||||
const current: ProjectSelection = {
|
||||
contextKey: "host:route-project",
|
||||
projectKey: manual.projectKey,
|
||||
project: manual,
|
||||
source: "manual",
|
||||
};
|
||||
const selectionContext = context({
|
||||
contextKey: "host:route-project",
|
||||
initialProject: routeProject,
|
||||
projects: [manual],
|
||||
routeProject,
|
||||
lastActiveProject: remembered,
|
||||
});
|
||||
|
||||
expect(resolveProjectSelection(current, selectionContext)).toEqual(manual);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
|
||||
export type ProjectSelectionSource = "initial" | "manual";
|
||||
export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null;
|
||||
|
||||
export interface ProjectSelection {
|
||||
contextKey: string;
|
||||
projectKey: string | null;
|
||||
project: HostProjectListItem | null;
|
||||
source: ProjectSelectionSource;
|
||||
}
|
||||
|
||||
export interface ProjectSelectionContext {
|
||||
contextKey: string;
|
||||
manualContextKey: string;
|
||||
initialProject: HostProjectListItem | null;
|
||||
initialProjectSource: InitialProjectSelectionSource;
|
||||
projects: HostProjectListItem[];
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
shouldPreserveMissingProject: (project: HostProjectListItem) => boolean;
|
||||
}
|
||||
|
||||
export function createProjectSelectionContextKey(input: {
|
||||
selectedServerId: string;
|
||||
routeProjectKey: string | null;
|
||||
allowAllProjects: boolean;
|
||||
}): string {
|
||||
const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects";
|
||||
return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`;
|
||||
}
|
||||
|
||||
export function createManualProjectSelectionContextKey(input: {
|
||||
selectedServerId: string;
|
||||
routeProjectKey: string | null;
|
||||
}): string {
|
||||
return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`;
|
||||
}
|
||||
|
||||
export function createProjectSelection({
|
||||
contextKey,
|
||||
initialProject,
|
||||
}: ProjectSelectionContext): ProjectSelection {
|
||||
return {
|
||||
contextKey,
|
||||
projectKey: initialProject?.projectKey ?? null,
|
||||
project: initialProject,
|
||||
source: "initial",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInitialProjectSelectionSource(input: {
|
||||
initialProject: HostProjectListItem | null;
|
||||
routeProject: HostProjectListItem | null;
|
||||
lastActiveProject: HostProjectListItem | null;
|
||||
}): InitialProjectSelectionSource {
|
||||
if (!input.initialProject) {
|
||||
return null;
|
||||
}
|
||||
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
|
||||
return "route";
|
||||
}
|
||||
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
|
||||
return "lastActive";
|
||||
}
|
||||
return "fallback";
|
||||
}
|
||||
|
||||
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
|
||||
const projectKey = selection.projectKey?.trim() ?? "";
|
||||
return projectKey || null;
|
||||
}
|
||||
|
||||
function resolveSelectedProjectFromInitialInputs(
|
||||
projectKey: string,
|
||||
context: ProjectSelectionContext,
|
||||
): HostProjectListItem | null {
|
||||
return (
|
||||
(context.routeProject?.projectKey === projectKey ? context.routeProject : null) ??
|
||||
(context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null)
|
||||
);
|
||||
}
|
||||
|
||||
function refreshSelectionProject(
|
||||
selection: ProjectSelection,
|
||||
project: HostProjectListItem,
|
||||
): ProjectSelection {
|
||||
if (selection.projectKey === project.projectKey && selection.project === project) {
|
||||
return selection;
|
||||
}
|
||||
return {
|
||||
...selection,
|
||||
projectKey: project.projectKey,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldResetInitialFallbackSelection(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): boolean {
|
||||
if (
|
||||
selection.source !== "initial" ||
|
||||
!context.initialProject ||
|
||||
context.initialProjectSource !== "lastActive"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selection.projectKey !== context.initialProject.projectKey;
|
||||
}
|
||||
|
||||
export function resolveProjectSelection(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): HostProjectListItem | null {
|
||||
const projectKey = resolveProjectSelectionKey(selection);
|
||||
if (!projectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectableProject = context.projects.find((project) => project.projectKey === projectKey);
|
||||
if (selectableProject) {
|
||||
return selectableProject;
|
||||
}
|
||||
|
||||
if (
|
||||
selection.project?.projectKey === projectKey &&
|
||||
context.shouldPreserveMissingProject(selection.project)
|
||||
) {
|
||||
return selection.project;
|
||||
}
|
||||
|
||||
if (selection.source !== "manual") {
|
||||
return resolveSelectedProjectFromInitialInputs(projectKey, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reconcileProjectSelection(
|
||||
current: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): ProjectSelection {
|
||||
const initialSelection = createProjectSelection(context);
|
||||
const currentContextKey =
|
||||
current.source === "manual" ? context.manualContextKey : context.contextKey;
|
||||
if (current.contextKey !== currentContextKey) {
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
if (shouldResetInitialFallbackSelection(current, context)) {
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
const resolvedProject = resolveProjectSelection(current, context);
|
||||
if (resolvedProject) {
|
||||
return refreshSelectionProject(current, resolvedProject);
|
||||
}
|
||||
|
||||
return initialSelection;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useMemo, useState, 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 { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
||||
import { SchedulesTable } 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 type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type FormState =
|
||||
| { mode: "closed" }
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
||||
|
||||
export function SchedulesScreen(): ReactElement {
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
if (!isFocused) {
|
||||
return <View style={styles.container} />;
|
||||
}
|
||||
|
||||
return <SchedulesScreenContent />;
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
||||
<SchedulesBody
|
||||
sections={sections}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
<ScheduleFormSheet
|
||||
serverId={form.mode === "edit" ? form.serverId : undefined}
|
||||
visible={form.mode === "create" || form.mode === "edit"}
|
||||
onClose={closeForm}
|
||||
mode={form.mode === "edit" ? "edit" : "create"}
|
||||
schedule={form.mode === "edit" ? form.schedule : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesBody({
|
||||
sections,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
onCreate,
|
||||
onEdit,
|
||||
}: {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
onCreate: () => void;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
}): ReactElement {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
||||
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>No schedules yet</Text>
|
||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-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>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[6],
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sectionError: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
sectionEmpty: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
sectionEmptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
message: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.lg,
|
||||
textAlign: "center",
|
||||
},
|
||||
// Static color holder read by the spinner; keeps the muted token without
|
||||
// useUnistyles (banned in new code).
|
||||
spinner: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import {
|
||||
createBrowserToolsPatch,
|
||||
getBrowserToolsCardState,
|
||||
getBrowserToolsMutationViewState,
|
||||
} from "./browser-tools-config";
|
||||
|
||||
export function BrowserToolsOptInCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const state = getBrowserToolsCardState({ isConnected, config });
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
const result = await patchConfig(createBrowserToolsPatch(next));
|
||||
if (!result) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const mutationView = getBrowserToolsMutationViewState({
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
});
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(next: boolean) => {
|
||||
mutation.mutate(next);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
if (!state.isVisible) return null;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-browser-tools-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{state.title}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{state.warning}</Text>
|
||||
{mutationView.loadingText ? (
|
||||
<Text style={settingsStyles.rowHint} testID="host-page-browser-tools-loading">
|
||||
{mutationView.loadingText}
|
||||
</Text>
|
||||
) : null}
|
||||
{mutationView.errorText ? (
|
||||
<Text style={settingsStyles.rowError} testID="host-page-browser-tools-error">
|
||||
{mutationView.errorText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={state.isEnabled}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={mutationView.isSwitchDisabled}
|
||||
accessibilityLabel="Enable browser tools"
|
||||
testID="host-page-browser-tools-switch"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BROWSER_TOOLS_WARNING,
|
||||
createBrowserToolsPatch,
|
||||
getBrowserToolsCardState,
|
||||
getBrowserToolsMutationViewState,
|
||||
} from "./browser-tools-config";
|
||||
|
||||
function makeConfig(browserToolsEnabled = false): MutableDaemonConfig {
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: browserToolsEnabled },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser tools opt-in config", () => {
|
||||
it("shows the card with the logged-in browser state warning when connected", () => {
|
||||
expect(getBrowserToolsCardState({ isConnected: true, config: makeConfig(false) })).toEqual({
|
||||
isVisible: true,
|
||||
isEnabled: false,
|
||||
title: "Browser tools",
|
||||
warning: BROWSER_TOOLS_WARNING,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads enabled state from daemon config", () => {
|
||||
expect(getBrowserToolsCardState({ isConnected: true, config: makeConfig(true) })).toMatchObject(
|
||||
{
|
||||
isEnabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the card when the host is disconnected", () => {
|
||||
expect(
|
||||
getBrowserToolsCardState({ isConnected: false, config: makeConfig(true) }),
|
||||
).toMatchObject({
|
||||
isVisible: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes daemon.browserTools.enabled when toggled", () => {
|
||||
expect(createBrowserToolsPatch(true)).toEqual({ browserTools: { enabled: true } });
|
||||
expect(createBrowserToolsPatch(false)).toEqual({ browserTools: { enabled: false } });
|
||||
});
|
||||
|
||||
it("shows loading and disables the toggle while browser tool settings save", () => {
|
||||
expect(getBrowserToolsMutationViewState({ isPending: true, error: null })).toEqual({
|
||||
isSwitchDisabled: true,
|
||||
loadingText: "Updating browser tools…",
|
||||
errorText: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the save error when browser tool settings fail", () => {
|
||||
expect(
|
||||
getBrowserToolsMutationViewState({ isPending: false, error: new Error("Disk full") }),
|
||||
).toEqual({
|
||||
isSwitchDisabled: false,
|
||||
loadingText: null,
|
||||
errorText: "Disk full",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
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.";
|
||||
|
||||
export interface BrowserToolsCardState {
|
||||
isVisible: boolean;
|
||||
isEnabled: boolean;
|
||||
title: string;
|
||||
warning: string;
|
||||
}
|
||||
|
||||
export interface BrowserToolsMutationViewState {
|
||||
isSwitchDisabled: boolean;
|
||||
loadingText: string | null;
|
||||
errorText: string | null;
|
||||
}
|
||||
|
||||
export function getBrowserToolsCardState(input: {
|
||||
isConnected: boolean;
|
||||
config: MutableDaemonConfig | null;
|
||||
}): BrowserToolsCardState {
|
||||
return {
|
||||
isVisible: input.isConnected,
|
||||
isEnabled: input.config?.browserTools.enabled === true,
|
||||
title: BROWSER_TOOLS_TITLE,
|
||||
warning: BROWSER_TOOLS_WARNING,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrowserToolsPatch(enabled: boolean): Partial<MutableDaemonConfig> {
|
||||
return { browserTools: { enabled } };
|
||||
}
|
||||
|
||||
export function getBrowserToolsMutationViewState(input: {
|
||||
isPending: boolean;
|
||||
error: unknown;
|
||||
}): BrowserToolsMutationViewState {
|
||||
return {
|
||||
isSwitchDisabled: input.isPending,
|
||||
loadingText: input.isPending ? "Updating browser tools…" : null,
|
||||
errorText: input.error ? toErrorMessage(input.error) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { formatLatency } from "@/utils/latency";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedArrowDown = withUnistyles(ArrowDown);
|
||||
@@ -262,6 +263,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
{isConnected ? (
|
||||
<SettingsSection title={t("settings.hostSections.agents")}>
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<BrowserToolsOptInCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user