mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 06:12:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4af508e2a | ||
|
|
d75fa81e93 | ||
|
|
21cfd75222 | ||
|
|
3834773414 | ||
|
|
919c737c19 | ||
|
|
c5b4b7bae3 | ||
|
|
81fc6134a9 | ||
|
|
9744da6e4f | ||
|
|
86df0587ba | ||
|
|
e26eb483e2 | ||
|
|
ee89f4917f | ||
|
|
c5236c00d8 | ||
|
|
d1443064db | ||
|
|
9f387873b0 | ||
|
|
12cd5345d2 | ||
|
|
2c6d4e5283 | ||
|
|
006bae2897 | ||
|
|
437aef1a7b | ||
|
|
b44e7b0b90 | ||
|
|
09f87df1b2 | ||
|
|
b013e0d428 | ||
|
|
c938e1cf44 | ||
|
|
0c18144967 | ||
|
|
c481ecf3e1 | ||
|
|
dfc9add774 | ||
|
|
da48803a43 | ||
|
|
7f5d32cdd5 | ||
|
|
dec2d861ec | ||
|
|
30178c4f58 | ||
|
|
52d345db7f | ||
|
|
a50a476009 | ||
|
|
6ca001c3e4 | ||
|
|
78a0f093eb | ||
|
|
d7b7016cc9 | ||
|
|
e1c769c018 | ||
|
|
c081e0350d | ||
|
|
e2de6df2e2 | ||
|
|
c906c2f4a4 | ||
|
|
fffd76e8fb | ||
|
|
d2e2154a8a | ||
|
|
cb9654a65c | ||
|
|
3dc17b7f30 | ||
|
|
76a9781ba5 | ||
|
|
a272a22d7e | ||
|
|
26f227bed9 | ||
|
|
aa3ffaeb6c | ||
|
|
513f2a9ea2 | ||
|
|
8cd989529e | ||
|
|
04c3e003f8 | ||
|
|
e68553f759 | ||
|
|
dc9799f6fc | ||
|
|
43a2a7969c | ||
|
|
90d978ab68 | ||
|
|
a048094da4 | ||
|
|
315803688d | ||
|
|
bf4dc22c2f | ||
|
|
5c9f767d51 | ||
|
|
84304b5532 | ||
|
|
28507224dc | ||
|
|
db4fd33406 | ||
|
|
e3c853df58 | ||
|
|
ea7a741851 |
@@ -1,5 +1,82 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
|
||||
|
||||
## 0.10.2 - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
|
||||
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||
|
||||
## 0.10.0 - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
|
||||
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
|
||||
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
|
||||
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
|
||||
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
|
||||
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
|
||||
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
|
||||
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
|
||||
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
|
||||
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
|
||||
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
|
||||
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
|
||||
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
|
||||
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
|
||||
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
|
||||
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
|
||||
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
|
||||
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
|
||||
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
+6
-2
@@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
||||
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
||||
|
||||
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ $PASEO_HOME/
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── local-credential # Per-run local client credential (mode 0600)
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {sanitized-cwd}/
|
||||
@@ -557,6 +558,7 @@ These small files are not validated as full Zod schemas but are persisted under
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **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, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
@@ -10,6 +10,8 @@ Two contracts follow from it.
|
||||
|
||||
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
||||
|
||||
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
|
||||
|
||||
- New fields are `.optional()` with a sensible default.
|
||||
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
||||
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
||||
|
||||
+11
-3
@@ -69,7 +69,7 @@ model; it does not override the model list returned by a resolver.
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
@@ -79,6 +79,8 @@ A provider that can register runtime tools directly should set `supportsNativePa
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
@@ -95,9 +97,15 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Fixed
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
|
||||
- Fixed completed OpenCode v2 edits showing no diff
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Fixed
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
|
||||
- Fixed completed OpenCode v2 edits showing no diff
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Fixed
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
|
||||
- Fixed completed OpenCode v2 edits showing no diff
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Fixed
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
|
||||
- Fixed completed OpenCode v2 edits showing no diff
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
sha256-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
|
||||
|
||||
Generated
+242
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -8271,6 +8271,84 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -8644,6 +8722,48 @@
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
|
||||
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.10",
|
||||
"@opencode/schema": "2.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
|
||||
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.10",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
|
||||
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -18622,6 +18742,16 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-rc.112",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
|
||||
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-check": "^4.9.0",
|
||||
"msgpackr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
@@ -21866,6 +21996,28 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -28141,6 +28293,37 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
@@ -28466,6 +28649,21 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -30141,6 +30339,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -36196,7 +36410,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37262,12 +37476,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"@getpaseo/client": "0.10.3",
|
||||
"@getpaseo/protocol": "0.10.3",
|
||||
"@getpaseo/server": "0.10.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37728,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/protocol": "0.10.3",
|
||||
"@getpaseo/relay": "0.10.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37742,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37986,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38882,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38911,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.3",
|
||||
"@getpaseo/protocol": "0.10.3",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38928,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.3",
|
||||
"@getpaseo/protocol": "0.10.3",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38951,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38978,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,19 +39196,20 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/client": "0.10.3",
|
||||
"@getpaseo/highlight": "0.10.3",
|
||||
"@getpaseo/plugin": "0.10.3",
|
||||
"@getpaseo/protocol": "0.10.3",
|
||||
"@getpaseo/relay": "0.10.3",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -39895,7 +40110,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectCreateProfileFromModelRow,
|
||||
expectComposerModel,
|
||||
expectModelRowProfileActionBesideRow,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
selectModelRow,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-row-action-",
|
||||
title: "Agent profiles row action",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
const oneMinute = {
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modelLabel: "One minute stream",
|
||||
};
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectModelRowProfileActionBesideRow(page, oneMinute);
|
||||
await expectCreateProfileFromModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await openModelPicker(page);
|
||||
await selectModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, oneMinute.modelLabel);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
@@ -25,3 +28,39 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID(), {
|
||||
paseoHome: await createHomeWithSlowProvider(1_800),
|
||||
});
|
||||
try {
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
|
||||
const provider = path.join(paseoHome, "slow-provider");
|
||||
await writeFile(
|
||||
provider,
|
||||
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
|
||||
})}\n`,
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Absolute file link tooltip",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
initialPrompt: "Show the file link",
|
||||
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await workspaceRow.click();
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import {
|
||||
buildPairingLink,
|
||||
cancelHostConfirmation,
|
||||
connectToConfirmedHost,
|
||||
expectHostConfirmationFor,
|
||||
expectHostInHostPicker,
|
||||
hostConfirmation,
|
||||
openPairingLink,
|
||||
saveLinkedHostBeforeLoad,
|
||||
type LinkedHost,
|
||||
} from "../support/helpers/host-confirmation";
|
||||
|
||||
// An unreachable relay: the link saves the host without connecting first.
|
||||
const linkedHost: LinkedHost = {
|
||||
serverId: "srv_link_confirmation",
|
||||
relayEndpoint: "127.0.0.1:59998",
|
||||
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
|
||||
};
|
||||
|
||||
test("a pairing link for a new host asks before saving it", async ({ page }) => {
|
||||
const link = buildPairingLink(linkedHost);
|
||||
|
||||
await test.step("Cancel saves nothing and stays on the page", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await cancelHostConfirmation(page);
|
||||
await expectAppRoute(page, "/settings/general");
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
|
||||
});
|
||||
|
||||
await test.step("Connect saves the host and opens a project", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await connectToConfirmedHost(page);
|
||||
await expectAppRoute(page, "/open-project");
|
||||
await openSettings(page);
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pairing link for a saved host connects without asking", async ({ page }) => {
|
||||
await saveLinkedHostBeforeLoad(page, linkedHost);
|
||||
|
||||
await openPairingLink(page, buildPairingLink(linkedHost));
|
||||
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
});
|
||||
|
||||
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
|
||||
const guessed: TerminalProfile = {
|
||||
id: "e2e-cursor-guessed",
|
||||
name: "Cursor guessed",
|
||||
command: "cursor-agent",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
const explicit: TerminalProfile = {
|
||||
...guessed,
|
||||
id: "e2e-cursor-explicit",
|
||||
name: "Cursor explicit",
|
||||
icon: "cursor",
|
||||
};
|
||||
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
const menuIconPath = (name: string) =>
|
||||
menu
|
||||
.getByRole("menuitem", { name })
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedMenuPath = await menuIconPath(guessed.name);
|
||||
const explicitMenuPath = await menuIconPath(explicit.name);
|
||||
|
||||
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
|
||||
const settingsIconPath = (id: string) =>
|
||||
page
|
||||
.getByTestId(`terminal-profile-row-${id}`)
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedSettingsPath = await settingsIconPath(guessed.id);
|
||||
const explicitSettingsPath = await settingsIconPath(explicit.id);
|
||||
|
||||
expect(explicitMenuPath).not.toBeNull();
|
||||
expect(explicitSettingsPath).not.toBeNull();
|
||||
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
|
||||
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
|
||||
} finally {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
|
||||
},
|
||||
});
|
||||
|
||||
async function selectMedium(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select thinking option/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function selectLongcat(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select model \(/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText("LongCat 2.5 Preview Free", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
async function askForReply(handle: AgentHandle) {
|
||||
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
|
||||
return handle.client.waitForFinish(handle.agentId, 120_000);
|
||||
}
|
||||
|
||||
test("switching an existing OpenCode session clears unsupported thinking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
cwd,
|
||||
provider: "opencode",
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opencode/space-bunny-free" },
|
||||
});
|
||||
await selectMedium(page);
|
||||
expect((await askForReply(handle)).status).toBe("idle");
|
||||
await selectLongcat(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
const finish = await askForReply(handle);
|
||||
const screenshot = testInfo.outputPath("model-switch.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("After switching to Longcat", {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeFalsy();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ visible: true }).last(),
|
||||
).toContainText("OK");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -120,3 +121,75 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
|
||||
const MANY_THEMES_COUNT = 40;
|
||||
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
|
||||
|
||||
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
|
||||
plugin.addTheme({
|
||||
id: "pack-" + index,
|
||||
name: "Pack theme " + index,
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
@@ -18,6 +19,12 @@ import {
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
expectHostRejectedWithReAddGuidance,
|
||||
expectNoHostPasswordControls,
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -89,6 +96,47 @@ test.describe("Settings host page", () => {
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a host rejected for its password reconnects after being removed and added again with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const password = "e2e host password";
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
|
||||
environment: { ...process.env, PASEO_PASSWORD: password },
|
||||
});
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Password host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, daemon.serverId);
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
|
||||
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
|
||||
await expectHostRejectedWithReAddGuidance(page, "Password required");
|
||||
await expectNoHostPasswordControls(page);
|
||||
});
|
||||
|
||||
await test.step("removing the host and adding it again with the password brings it online", async () => {
|
||||
await removeHostFromHostPage(page, daemon.serverId);
|
||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
await expectHostOnlineWithoutError(page);
|
||||
});
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
@@ -108,7 +110,207 @@ async function withPaseoOwnedWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
async function readCachedRowIds(
|
||||
page: Page,
|
||||
rowKind: "workspace" | "checkpoint",
|
||||
): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
async ({ serverId, kind }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readonly");
|
||||
const request = transaction.objectStore("rows").getAll();
|
||||
request.onsuccess = () =>
|
||||
resolve(
|
||||
request.result
|
||||
.filter(
|
||||
(row: { serverId: string; kind: string }) =>
|
||||
row.serverId === serverId && row.kind === kind,
|
||||
)
|
||||
.map((row: { id: string }) => row.id)
|
||||
.sort(),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), kind: rowKind },
|
||||
);
|
||||
}
|
||||
|
||||
async function simulateDamagedLegacyDirectoryCache(
|
||||
page: Page,
|
||||
workspaceIdToRemove: string,
|
||||
): Promise<void> {
|
||||
// Stop the app before altering storage so no pending app write can repair the fixture.
|
||||
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
|
||||
await page.route(fixtureUrl, (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
|
||||
);
|
||||
await page.goto(fixtureUrl);
|
||||
await page.evaluate(
|
||||
async ({ serverId, missingWorkspaceId }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readwrite");
|
||||
const store = transaction.objectStore("rows");
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result as Array<{
|
||||
serverId: string;
|
||||
kind: string;
|
||||
id: string;
|
||||
payload: string;
|
||||
}>;
|
||||
const checkpoint = rows.find(
|
||||
(row) => row.serverId === serverId && row.kind === "checkpoint",
|
||||
);
|
||||
if (!checkpoint) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(checkpoint.payload);
|
||||
// Accept either writer so this fixture also reproduces on the unfixed client.
|
||||
const cursors = stored.version === 1 ? stored.cursors : stored;
|
||||
if (!cursors.workspaces?.generation) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.serverId === serverId &&
|
||||
row.kind === "workspace" &&
|
||||
row.id === missingWorkspaceId
|
||||
)
|
||||
store.delete([serverId, row.kind, row.id]);
|
||||
}
|
||||
};
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("abort", () =>
|
||||
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
|
||||
);
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
|
||||
);
|
||||
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
|
||||
}
|
||||
|
||||
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
server.onMessage(async (message) => {
|
||||
await ready;
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
async function createPinnedSiblingWorkspace(
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
): Promise<string> {
|
||||
const sibling = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Pinned sibling",
|
||||
});
|
||||
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
|
||||
await project.client.setWorkspacePinned(sibling.workspace.id, true);
|
||||
return sibling.workspace.id;
|
||||
}
|
||||
|
||||
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await waitForSidebarWorkspace(page, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
}
|
||||
|
||||
async function reopenDamagedCacheBeforeHostResponds(
|
||||
page: Page,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<() => void> {
|
||||
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
|
||||
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
|
||||
await gotoAppShell(page);
|
||||
return releaseHost;
|
||||
}
|
||||
|
||||
async function expectDamagedCacheHydrated(
|
||||
page: Page,
|
||||
retainedWorkspaceId: string,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
await waitForSidebarWorkspace(page, retainedWorkspaceId);
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
|
||||
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectRecoveredWorkspacesAfterReload(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
testInfo: TestInfo,
|
||||
): Promise<void> {
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
await page.reload();
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
|
||||
await testInfo.attach("recovered-workspaces", {
|
||||
path: testInfo.outputPath("recovered-workspaces.png"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
|
||||
try {
|
||||
const siblingId = await createPinnedSiblingWorkspace(project);
|
||||
const ids = [project.workspaceId, siblingId].sort();
|
||||
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
|
||||
|
||||
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
|
||||
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
|
||||
await test.step("keep cached workspace and discard stale checkpoint", () =>
|
||||
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
|
||||
|
||||
releaseHost();
|
||||
await test.step("recover missing workspace and retain both after reload", () =>
|
||||
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId, agentId: parentId });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
|
||||
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-agent").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${parentId}`)
|
||||
.click();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
|
||||
const left = page.getByTestId("workspace-pane-main");
|
||||
const right = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
|
||||
}, SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
const childPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
|
||||
await expect(childPane).toBeVisible();
|
||||
expect(await childPane.getAttribute("data-testid")).not.toBe(
|
||||
await right.getAttribute("data-testid"),
|
||||
);
|
||||
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-file-origin-pane-",
|
||||
title: "File link parent",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||
const { left, right } = await openParentInRightPane(
|
||||
page,
|
||||
workspace.agentId,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const fileLink = right.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
|
||||
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
Keep generated screenshots, recordings, and run logs outside the repository.
|
||||
|
||||
## Stacked adaptive sheets
|
||||
|
||||
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
|
||||
confirmation. It first types without tapping the input to verify autofocus, then cancels the
|
||||
confirmation, closes and reopens the underlying sheet, confirms again,
|
||||
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
|
||||
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
|
||||
bottom sheet and centered dialog are exercised.
|
||||
|
||||
These integration journeys require the host-confirmation flow in the test app. Include both the
|
||||
sheet fix and that flow in the QA checkout when verifying them; the production sheet component
|
||||
has no dependency on the host-confirmation feature.
|
||||
|
||||
Use a fresh English app with notification permission granted and first-use keyboard prompts
|
||||
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
|
||||
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
|
||||
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
|
||||
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
|
||||
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
|
||||
daemon accepts relay connections with no credential.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
|
||||
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
|
||||
--udid "$SHEET_QA_IOS_UDID" \
|
||||
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
|
||||
--env OUT="$SHEET_QA_OUTPUT"
|
||||
```
|
||||
|
||||
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
|
||||
first. Use a second loopback spelling
|
||||
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
|
||||
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
|
||||
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
|
||||
|
||||
Android uses the same journeys: copy each script outside the repository, change its context's
|
||||
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
|
||||
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
|
||||
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
|
||||
default mobile suite, alongside the existing modal-sheet scripts.
|
||||
|
||||
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
|
||||
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
|
||||
URI one character at a time passed with both implementations; faster typing remains unverified.
|
||||
On iPad, replacing the field a second time appends text. For this Settings journey, use the
|
||||
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
|
||||
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
|
||||
runs end to end without these adjustments.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
|
||||
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"welcome-paste-pairing-link\"" 20000
|
||||
screenshot "${OUT}/01-welcome.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
# Type without tapping the input: opening the sheet must retain its autofocus.
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
type "relay://"
|
||||
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
|
||||
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/02-pairing-input.png"
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/03-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
|
||||
# Close and reopen the underlying sheet, then edit it and submit again.
|
||||
press "id=\"pair-link-cancel\""
|
||||
wait stable
|
||||
wait "id=\"welcome-paste-pairing-link\"" 10000
|
||||
screenshot "${OUT}/05-underlying-sheet-closes.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/06-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-password-input\"" 20000
|
||||
screenshot "${OUT}/07-password-after-connect.png"
|
||||
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait text "Open a folder on your machine" 30000
|
||||
screenshot "${OUT}/08-connected.png"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Settings with Add host visible: no saved hosts, or the host picker already open.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"settings-add-host\"" 20000
|
||||
screenshot "${OUT}/09-settings.png"
|
||||
press "id=\"settings-add-host\""
|
||||
wait stable
|
||||
wait "id=\"add-host-method-direct\"" 10000
|
||||
screenshot "${OUT}/10-add-host-methods.png"
|
||||
press "id=\"add-host-method-direct\""
|
||||
wait stable
|
||||
press "id=\"direct-host-advanced-toggle\""
|
||||
wait stable
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/11-add-host-relay-input.png"
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"direct-host-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
|
||||
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"direct-password-input\"" 20000
|
||||
screenshot "${OUT}/15-add-host-password.png"
|
||||
fill "id=\"direct-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
open "${APP_ID}" "paseo://settings"
|
||||
wait "label=\"Connections\"" 10000
|
||||
press "label=\"Connections\""
|
||||
wait stable
|
||||
wait "id=\"host-page-connections-card\"" 30000
|
||||
screenshot "${OUT}/16-settings-connected.png"
|
||||
@@ -482,6 +482,50 @@ export async function expectModelRowSelected(
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
|
||||
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A model row's profile action is its own button. On web, a button inside the
|
||||
* row's button is invalid HTML that React reports on every render.
|
||||
*/
|
||||
export async function expectModelRowProfileActionBesideRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const row = modelRow(page, input);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
|
||||
await expect(row.getByRole("button")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
|
||||
export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
input.modelLabel,
|
||||
);
|
||||
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(modal).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function selectModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).click();
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function splitCurrentPanelRight(page: Page): Promise<void> {
|
||||
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
||||
const timelineUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "chat");
|
||||
await page.getByRole("switch", { name: "Chat outline" }).click();
|
||||
await page.goto(timelineUrl);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
export interface LinkedHost {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
daemonPublicKeyB64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pairing link that opens on the settings page. `/` redirects to
|
||||
* `/open-project` on its own, so starting in settings leaves the pairing link
|
||||
* as the only thing that can move the app to `/open-project`.
|
||||
*/
|
||||
export function buildPairingLink(host: LinkedHost): string {
|
||||
const offer = {
|
||||
v: 2,
|
||||
serverId: host.serverId,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
relay: { endpoint: host.relayEndpoint, useTls: false },
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
|
||||
return `/settings/general#offer=${encoded}`;
|
||||
}
|
||||
|
||||
/** Loads the app from a pairing link, the way opening the link in a browser does. */
|
||||
export async function openPairingLink(page: Page, link: string): Promise<void> {
|
||||
// Leave the link's page first so the link always loads the app from scratch.
|
||||
await page.goto("/");
|
||||
await page.goto(link);
|
||||
}
|
||||
|
||||
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
|
||||
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const connectionId = `relay:${host.relayEndpoint}`;
|
||||
const savedHost = {
|
||||
serverId: host.serverId,
|
||||
label: host.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
type: "relay",
|
||||
relayEndpoint: host.relayEndpoint,
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: connectionId,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
|
||||
key: EXTRA_HOSTS_KEY,
|
||||
hosts: [savedHost],
|
||||
});
|
||||
}
|
||||
|
||||
export function hostConfirmation(page: Page): Locator {
|
||||
return page.getByTestId("host-confirmation");
|
||||
}
|
||||
|
||||
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
|
||||
const sheet = hostConfirmation(page);
|
||||
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
|
||||
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
|
||||
}
|
||||
|
||||
export async function cancelHostConfirmation(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function connectToConfirmedHost(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Checks the host picker on the settings page the app is already showing. */
|
||||
export async function expectHostInHostPicker(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
expected: "listed" | "not listed",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
|
||||
expected === "listed" ? 1 : 0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function openPluginScreen(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ interface SavedSettingsHostInput {
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
@@ -91,12 +95,15 @@ export async function selectHostConnectionType(
|
||||
|
||||
export async function addDirectHostFromSettings(
|
||||
page: Page,
|
||||
input: { host: string; port: number },
|
||||
input: { host: string; port: number; password?: string },
|
||||
): Promise<void> {
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await page.getByTestId("direct-host-input").fill(input.host);
|
||||
await page.getByTestId("direct-port-input").fill(String(input.port));
|
||||
if (input.password) {
|
||||
await page.getByTestId("direct-password-input").fill(input.password);
|
||||
}
|
||||
await page.getByTestId("direct-host-submit").click();
|
||||
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
@@ -418,3 +425,31 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostRejectedWithReAddGuidance(
|
||||
page: Page,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const error = page.getByTestId("host-connection-error");
|
||||
await expect(error).toContainText(reason);
|
||||
await expect(error).toContainText(
|
||||
"Remove this host and add it again with the password this daemon asks for.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectNoHostPasswordControls(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Password", { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /password/i })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function removeHostFromHostPage(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("host-page-remove-host-button").click();
|
||||
await page.getByTestId("remove-host-confirm").click();
|
||||
await expect(page).not.toHaveURL(new RegExp(`/settings/hosts/${serverId}`));
|
||||
}
|
||||
|
||||
export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences(
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"resourceClass": "large"
|
||||
"resourceClass": "large",
|
||||
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
// Codex streams a line break and the next line's indent as their own chunks.
|
||||
it("keeps a line break when a chunk ends in the next line's indent", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
|
||||
harness.send(assistant(" "));
|
||||
const result = harness.send(assistant(" B --> C[Finish]\n```"));
|
||||
expect(result.head).toMatchObject([
|
||||
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
|
||||
]);
|
||||
});
|
||||
|
||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
|
||||
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
|
||||
const index = prefix.length + offset;
|
||||
let blockText = text;
|
||||
if (offset === textBlocks.length - 1) {
|
||||
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingNewlines;
|
||||
// The split drops trailing blank lines, including a line break followed only
|
||||
// by the next line's indent. Keep them so the next append continues that line.
|
||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingBlankLines;
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
|
||||
@@ -45,6 +45,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -105,7 +106,6 @@ import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
useHosts,
|
||||
@@ -607,6 +607,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostConfirmationSheet />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
@@ -666,13 +667,11 @@ function MobileGestureWrapper({
|
||||
}
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
|
||||
return (
|
||||
<AppearanceProvider>
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<OfferLinkListener />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
@@ -699,45 +698,36 @@ function DesktopWindowControlsSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function OfferLinkListener({
|
||||
upsertDaemonFromOfferUrl,
|
||||
}: {
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
||||
}) {
|
||||
function OfferLinkListener() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const handleUrl = (url: string | null) => {
|
||||
const handleUrl = async (url: string | null) => {
|
||||
if (!url) return;
|
||||
if (!url.includes("#offer=")) return;
|
||||
void upsertDaemonFromOfferUrl(url)
|
||||
.then((profile) => {
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[Linking] Failed to import pairing offer", error);
|
||||
});
|
||||
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
|
||||
return;
|
||||
try {
|
||||
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
|
||||
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
|
||||
} catch (error) {
|
||||
console.warn("[OfferLinkListener] Pairing link failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
void Linking.getInitialURL()
|
||||
.then(handleUrl)
|
||||
.then((url) => handleUrl(url))
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscription = Linking.addEventListener("url", (event) => {
|
||||
handleUrl(event.url);
|
||||
void handleUrl(event.url);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
subscription.remove();
|
||||
};
|
||||
}, [router, upsertDaemonFromOfferUrl]);
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -75,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 4,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
scanAgainButton: {
|
||||
marginTop: theme.spacing[6],
|
||||
},
|
||||
helperText: {
|
||||
marginTop: theme.spacing[6],
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -110,15 +113,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw.includes("#offer=")) return raw;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -128,11 +122,8 @@ export default function PairScanScreen() {
|
||||
source?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -145,6 +136,19 @@ export default function PairScanScreen() {
|
||||
[router, source],
|
||||
);
|
||||
|
||||
const [scan] = useState(() =>
|
||||
openPairScan({
|
||||
importConnectionLink: (link) =>
|
||||
getHostRuntimeStore().importConnectionLink(
|
||||
link,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
}),
|
||||
);
|
||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||
useEffect(() => () => scan.close(), [scan]);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
try {
|
||||
router.back();
|
||||
@@ -160,48 +164,16 @@ export default function PairScanScreen() {
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
const handleScan = useCallback(
|
||||
async (result: BarcodeScanningResult) => {
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
|
||||
if (lastScannedRef.current === offerUrl) return;
|
||||
lastScannedRef.current = offerUrl;
|
||||
|
||||
try {
|
||||
setIsPairing(true);
|
||||
const idx = offerUrl.indexOf("#offer=");
|
||||
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
|
||||
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(offerPayload);
|
||||
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
||||
useTls: offer.relay.useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
},
|
||||
{ serverId: offer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||
|
||||
navigateToPairedHost(profile.serverId);
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
||||
Alert.alert(t("pairing.scan.errorTitle"), message);
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
||||
(result: BarcodeScanningResult) =>
|
||||
scan.scan(typeof result.data === "string" ? result.data : ""),
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
);
|
||||
const handleRequestPermission = useCallback(() => {
|
||||
void requestPermission();
|
||||
}, [requestPermission]);
|
||||
@@ -255,18 +227,40 @@ export default function PairScanScreen() {
|
||||
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
|
||||
onBarcodeScanned={handleScan}
|
||||
/>
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
style={styles.scanAgainButton}
|
||||
onPress={scan.scanAgain}
|
||||
testID="pair-scan-again"
|
||||
>
|
||||
{t("pairing.connectionMethods.scanQr.title")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PairLinkModal
|
||||
visible={scanState.status === "passwordRequired"}
|
||||
passwordRequired={
|
||||
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
|
||||
}
|
||||
onClose={scan.closePassword}
|
||||
onSaved={savePasswordPairing}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
@@ -34,7 +35,7 @@ export function AssistantMarkdownLink({
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
@@ -127,38 +128,6 @@ export function AssistantMarkdownCodeLink({
|
||||
);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
|
||||
describe("formatFileLinkTooltipPath", () => {
|
||||
it("shows a Windows file path relative to its workspace regardless of separators", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: {
|
||||
path: "C:/Users/me/repo/src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
},
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("src/app.ts:12-20");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/src/app.ts", lineStart: 12 },
|
||||
workspaceRoot: "C:\\",
|
||||
}),
|
||||
).toBe("src/app.ts:12");
|
||||
});
|
||||
|
||||
it("shows the workspace root as a dot", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/repo" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe(".");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
|
||||
workspaceRoot: "c:\\users\\me\\repo",
|
||||
}),
|
||||
).toBe(".:4");
|
||||
});
|
||||
|
||||
it("keeps an absolute path outside the workspace", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/notes.md" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe("/Users/me/notes.md");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/me/notes.md:8");
|
||||
});
|
||||
|
||||
it("keeps the target path when the workspace root is unavailable", () => {
|
||||
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
|
||||
"src/app.ts:12",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
isWorkspaceRootPath,
|
||||
resolveWorkspaceFilePaths,
|
||||
type WorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
|
||||
interface FormatFileLinkTooltipPathInput {
|
||||
target: WorkspaceFileLocation;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function formatFileLinkTooltipPath({
|
||||
target,
|
||||
workspaceRoot,
|
||||
}: FormatFileLinkTooltipPathInput): string {
|
||||
const resolvedPaths = workspaceRoot
|
||||
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
|
||||
: null;
|
||||
const isWorkspaceRoot = workspaceRoot
|
||||
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
|
||||
: false;
|
||||
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -112,6 +112,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Alert
|
||||
size="md"
|
||||
variant="error"
|
||||
title={t("changelog.error.title")}
|
||||
description={t("changelog.error.description")}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -75,11 +74,15 @@ export interface SheetHeader {
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeModalRoot: {
|
||||
nativeDialogSurface: {
|
||||
flex: 1,
|
||||
},
|
||||
nativeDialogBackground: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
@@ -471,7 +474,7 @@ export interface AdaptiveModalSheetProps {
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
|
||||
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
@@ -526,26 +529,26 @@ export function AdaptiveModalSheet({
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
// A newly opened sheet owns input. A keyboard belonging to the sheet below
|
||||
// would cover controls in the new sheet, which has no focused input yet.
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
isEnabled: isMobile || !isWeb,
|
||||
onClose,
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
const notifyNativeModalDismiss = useCallback(() => {
|
||||
if (nativeModalDismissNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeModalDismissNotifiedRef.current = true;
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -592,12 +595,6 @@ export function AdaptiveModalSheet({
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
nativeModalDismissNotifiedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile) return;
|
||||
if (visible) {
|
||||
@@ -615,12 +612,6 @@ export function AdaptiveModalSheet({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
@@ -725,18 +716,28 @@ export function AdaptiveModalSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
onDismiss={notifyNativeModalDismiss}
|
||||
hardwareAccelerated
|
||||
// Both native presentations share Gorhom's app-wide stack. Independent RN Modals
|
||||
// present from their React ancestor's controller, so a root-owned sibling dialog
|
||||
// cannot present while that controller already has a dialog open on iOS.
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={NATIVE_DIALOG_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
handleComponent={null}
|
||||
backgroundStyle={styles.nativeDialogBackground}
|
||||
enablePanDownToClose={false}
|
||||
enableHandlePanningGesture={false}
|
||||
enableContentPanningGesture={false}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
{/* Android Modal opens a separate window outside the app's gesture root. */}
|
||||
<GestureHandlerRootView style={styles.nativeModalRoot}>
|
||||
{desktopContent}
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
<View style={styles.nativeDialogSurface}>{desktopContent}</View>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -250,8 +254,8 @@ function buildConnectionFailureCopy(input: {
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (raw === "Incorrect password" || raw === "Password required") {
|
||||
detail = raw;
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
@@ -290,10 +294,23 @@ export interface AddHostModalProps {
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
return (
|
||||
<AddHostModalContent
|
||||
key={String(visible)}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection } = useHostMutations();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
const [linkPairing] = useState(() => beginLinkPairing());
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -306,17 +323,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [advancedUri, setAdvancedUri] = useState("");
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setHost("");
|
||||
setPort("6767");
|
||||
setUseTls(false);
|
||||
setPassword("");
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const connectIcon = useMemo(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
@@ -351,23 +358,55 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
|
||||
|
||||
// Each open mounts a fresh form (see AddHostModal), so closing needs no reset.
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSaveRelay = useCallback(
|
||||
async (relayUri: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const result = await linkPairing.submit(relayUri, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
daemons,
|
||||
directConnectionLabels.invalidConnection,
|
||||
handleClose,
|
||||
linkPairing,
|
||||
onSaved,
|
||||
password,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
|
||||
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
|
||||
await handleSaveRelay(relayUri);
|
||||
return;
|
||||
}
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
try {
|
||||
connection = prepareDirectConnection(
|
||||
@@ -422,10 +461,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
advancedUri,
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
host,
|
||||
isAdvancedOpen,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
@@ -433,6 +474,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
port,
|
||||
probeAndUpsertDirectConnection,
|
||||
t,
|
||||
handleSaveRelay,
|
||||
useTls,
|
||||
]);
|
||||
|
||||
@@ -444,6 +486,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
const handleChangeAdvancedUri = useCallback((next: string) => {
|
||||
if (advancedTarget.current.changeUrl(next)) {
|
||||
setPassword("");
|
||||
bumpInputResetKey();
|
||||
setErrorMessage("");
|
||||
}
|
||||
setAdvancedUri(next);
|
||||
}, []);
|
||||
|
||||
const handleToggleUseTls = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setUseTls((current) => !current);
|
||||
@@ -455,6 +506,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
try {
|
||||
setAdvancedUri(
|
||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||
@@ -612,7 +664,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
onChangeText={setAdvancedUri}
|
||||
onChangeText={handleChangeAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
|
||||
@@ -625,13 +625,6 @@ function ModelRowProfileAction({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const visible = hovered || isNative || isCompact;
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.rowIconButton,
|
||||
@@ -645,7 +638,7 @@ function ModelRowProfileAction({
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
@@ -778,7 +771,6 @@ function ModelRow({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.browserRow,
|
||||
styles.browserModelRow,
|
||||
Boolean(hovered) && styles.browserRowHovered,
|
||||
pressed && styles.browserRowPressed,
|
||||
],
|
||||
@@ -787,7 +779,7 @@ function ModelRow({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.modelRowHoverBoundary}
|
||||
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -815,10 +807,17 @@ function ModelRow({
|
||||
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
{profileAction}
|
||||
{profileAction ? <View style={styles.rowIconButton} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
</ModelBrowserPressable>
|
||||
{/* The row renders a <button> on web, so its profile action sits beside it,
|
||||
over the slot reserved above, rather than inside it. */}
|
||||
{profileAction ? (
|
||||
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
|
||||
{profileAction}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1590,6 +1589,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modelRowHoverBoundary: {
|
||||
position: "relative",
|
||||
},
|
||||
modelRowProfileActionSlot: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: isWeb ? theme.spacing[3] : theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
|
||||
browserRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
|
||||
describe("pairing target password", () => {
|
||||
it("clears the password and hides its input when switching hosts", () => {
|
||||
const first = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
||||
const second = "relay://relay.example:443/srv_b?key=BBBB&ssl=true";
|
||||
const target = new PairingTargetTracker(first);
|
||||
expect(target.changeUrl("relay://relay.example:443/")).toBe(false);
|
||||
expect(target.changeUrl(second)).toBe(true);
|
||||
expect(target.changeUrl(second)).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the direct form credential when its advanced URI changes to a relay target", () => {
|
||||
const target = new PairingTargetTracker("", true);
|
||||
expect(target.changeUrl("relay://relay.example:443/srv_new?key=BBBB&ssl=true")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the password when a relay URI receives harmless whitespace", () => {
|
||||
const uri = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
||||
const target = new PairingTargetTracker(uri);
|
||||
expect(target.changeUrl(`${uri} `)).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes a different host in an offer link after an incomplete edit", () => {
|
||||
const offer = Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
serverId: "srv_b",
|
||||
daemonPublicKeyB64: "BBBB",
|
||||
relay: { endpoint: "relay.example:443" },
|
||||
}),
|
||||
).toString("base64url");
|
||||
const target = new PairingTargetTracker("relay://relay.example:443/srv_a?key=AAAA");
|
||||
expect(target.changeUrl("https://app.paseo.sh/#offer=")).toBe(false);
|
||||
expect(target.changeUrl(`https://app.paseo.sh/#offer=${offer}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
|
||||
function pairingTarget(url: string): string | null {
|
||||
try {
|
||||
const trimmed = url.trim();
|
||||
const offer =
|
||||
trimmed.startsWith("relay://") || trimmed.includes("#connect=")
|
||||
? parseRelayConnectionUri(trimmed).offer
|
||||
: parseConnectionOfferFromUrl(trimmed);
|
||||
return offer ? `${offer.serverId}:${offer.daemonPublicKeyB64}` : null;
|
||||
} catch {
|
||||
// Keep the last valid target while the user edits an incomplete URI.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tracks the last valid host so harmless edits never clear its password. */
|
||||
export class PairingTargetTracker {
|
||||
private currentTarget: string | null;
|
||||
|
||||
constructor(initialUrl = "", startsFromDirectForm = false) {
|
||||
this.currentTarget = pairingTarget(initialUrl) ?? (startsFromDirectForm ? "direct-form" : null);
|
||||
}
|
||||
|
||||
changeUrl(nextUrl: string): boolean {
|
||||
const nextTarget = pairingTarget(nextUrl);
|
||||
if (!nextTarget) return false;
|
||||
const changed = this.currentTarget !== null && this.currentTarget !== nextTarget;
|
||||
this.currentTarget = nextTarget;
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,32 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
function parsedHostLabel(input: string): string {
|
||||
try {
|
||||
if (input.startsWith("relay://") || input.includes("#connect=")) {
|
||||
return parseRelayConnectionUri(input).offer.serverId;
|
||||
}
|
||||
return parseConnectionOfferFromUrl(input)?.serverId ?? "host";
|
||||
} catch {
|
||||
return "host";
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -50,6 +62,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
/** Continues a confirmed pairing whose host asked for a password. */
|
||||
passwordRequired?: PasswordRequiredPairing;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
@@ -60,21 +74,56 @@ export interface PairLinkModalProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||
export function PairLinkModal({
|
||||
visible,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
return (
|
||||
<PairLinkModalContent
|
||||
key={`${visible}:${passwordRequired?.link ?? ""}`}
|
||||
visible={visible}
|
||||
passwordRequired={passwordRequired}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PairLinkModalContent({
|
||||
visible,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
const [pairing] = useState(() => passwordRequired?.pairing ?? beginLinkPairing());
|
||||
const initialUrl = passwordRequired?.link;
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const offerUrlRef = useRef("");
|
||||
const offerUrlRef = useRef(initialUrl ?? "");
|
||||
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined);
|
||||
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
offerUrlRef.current = "";
|
||||
targetTracker.current = new PairingTargetTracker();
|
||||
inputRef.current?.replaceText("");
|
||||
setPassword("");
|
||||
setNeedsPassword(false);
|
||||
resetPasswordInput();
|
||||
}, []);
|
||||
|
||||
const pairIcon = useMemo(
|
||||
@@ -96,74 +145,53 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
const raw = offerUrlRef.current.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage(t("pairing.link.errors.required"));
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=")) {
|
||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||
return;
|
||||
}
|
||||
const handleSave = useCallback(
|
||||
async (input?: string) => {
|
||||
if (isSaving) return;
|
||||
const raw = (input ?? offerUrlRef.current).trim();
|
||||
if (!raw) {
|
||||
setErrorMessage(t("pairing.link.errors.required"));
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=") && !raw.startsWith("relay://") && !raw.includes("#connect=")) {
|
||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedOffer = (() => {
|
||||
try {
|
||||
const idx = raw.indexOf("#offer=");
|
||||
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error(t("pairing.link.errors.emptyOffer"));
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return ConnectionOfferSchema.parse(payload);
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const result = await pairing.submit(raw, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
setNeedsPassword(true);
|
||||
return;
|
||||
}
|
||||
if (!isMobile) {
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
})();
|
||||
|
||||
if (!parsedOffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
||||
useTls: parsedOffer.relay.useTls,
|
||||
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
||||
},
|
||||
{ serverId: parsedOffer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
||||
},
|
||||
[daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing],
|
||||
);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
if (targetTracker.current.changeUrl(next)) {
|
||||
setPassword("");
|
||||
setNeedsPassword(false);
|
||||
resetPasswordInput();
|
||||
setErrorMessage("");
|
||||
}
|
||||
offerUrlRef.current = next;
|
||||
}, []);
|
||||
|
||||
@@ -186,6 +214,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialUrl}
|
||||
testID="pair-link-input"
|
||||
nativeID="pair-link-input"
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
@@ -201,6 +230,22 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
{needsPassword ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||
</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="pair-link-password-input"
|
||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
|
||||
@@ -117,17 +117,16 @@ export function SettingsSelect<Value extends string>({
|
||||
disabled,
|
||||
...row
|
||||
}: SettingsSelectProps<Value>) {
|
||||
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={row.label}
|
||||
accessibilityLabel={`${row.label}: ${selectedLabel}`}
|
||||
>
|
||||
<Text style={styles.value}>
|
||||
{options.find((option) => option.value === value)?.label ?? value}
|
||||
</Text>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
{options.map((option) => (
|
||||
@@ -203,6 +202,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
accessoryLabel: { flexShrink: 1, minWidth: 0 },
|
||||
control: { flexShrink: 1, maxWidth: "100%" },
|
||||
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
input: { minWidth: 180 },
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SidebarHeaderRowProps {
|
||||
/**
|
||||
* "header" (default): a sidebar-height row with its own bottom separator —
|
||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||
* "compact": a workspace-row-height row with no separator, for entries that
|
||||
* "compact": a row with no separator, for entries that
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
@@ -52,10 +52,9 @@ export function SidebarHeaderRow({
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.button,
|
||||
variant === "compact" && styles.buttonCompact,
|
||||
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
||||
],
|
||||
[isActive, variant],
|
||||
[isActive],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
@@ -64,7 +63,7 @@ export function SidebarHeaderRow({
|
||||
return (
|
||||
<>
|
||||
<ThemedIcon
|
||||
size={ICON_SIZE.sm}
|
||||
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
@@ -74,7 +73,7 @@ export function SidebarHeaderRow({
|
||||
</>
|
||||
);
|
||||
},
|
||||
[ThemedIcon, isActive, label, shortcutKeys],
|
||||
[ThemedIcon, isActive, label, shortcutKeys, variant],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -129,21 +128,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Match the sidebar workspace-row shape (height, padding, radius) so the
|
||||
// compact header entries sit tight against the workspace list below.
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
// Compact header entries (New workspace / History) sit tighter than the
|
||||
// workspace-row shape the base button mirrors.
|
||||
buttonCompact: {
|
||||
minHeight: 32,
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the workspace list below (base button uses a wider spacing[3]).
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
|
||||
@@ -2,14 +2,21 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
type ButtonControlSize,
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { hexColorWithAlpha } from "@/utils/color";
|
||||
|
||||
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
export type AlertSize = ButtonControlSize;
|
||||
|
||||
export interface AlertProps {
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
variant?: AlertVariant;
|
||||
icon?: ReactNode;
|
||||
size?: AlertSize;
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -25,50 +32,93 @@ export function Alert({
|
||||
title,
|
||||
description,
|
||||
variant = "default",
|
||||
icon,
|
||||
size = "md",
|
||||
children,
|
||||
testID,
|
||||
}: AlertProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accentColor = resolveAccentColor(variant, theme);
|
||||
const borderColor = variant === "success" ? theme.colors.border : accentColor;
|
||||
const borderColor =
|
||||
variant === "success" || !accentColor
|
||||
? theme.colors.border
|
||||
: hexColorWithAlpha(accentColor, 0.5);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, borderColor ? { borderColor } : null],
|
||||
[borderColor],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, accentColor ? { color: accentColor } : null],
|
||||
[accentColor],
|
||||
);
|
||||
const sized = resolveSizeStyles(size);
|
||||
const containerStyle = [styles.container, sized.container, { borderColor }];
|
||||
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
|
||||
const iconSize = buttonIconSize[size];
|
||||
|
||||
const resolvedIcon = useMemo(() => {
|
||||
if (icon !== undefined) return icon;
|
||||
if (variant === "default") return null;
|
||||
const Icon = VARIANT_ICON[variant];
|
||||
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
||||
}, [icon, variant, theme, accentColor]);
|
||||
return <Icon size={iconSize} color={accentColor ?? theme.colors.foreground} />;
|
||||
}, [variant, theme, accentColor, iconSize]);
|
||||
|
||||
const hasDescription = description != null && description !== "";
|
||||
let descriptionContent: ReactNode = null;
|
||||
if (typeof description === "string" && description !== "") {
|
||||
descriptionContent = <Text style={[styles.description, sized.text]}>{description}</Text>;
|
||||
} else if (description != null && description !== "") {
|
||||
descriptionContent = <View style={styles.descriptionSlot}>{description}</View>;
|
||||
}
|
||||
const leadContent = title ? <Text style={titleStyle}>{title}</Text> : descriptionContent;
|
||||
const belowLead = title ? descriptionContent : null;
|
||||
const hasBody = belowLead !== null || Boolean(children);
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
||||
<View style={styles.body}>
|
||||
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
||||
{hasDescription && typeof description === "string" ? (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
<View style={[styles.lead, sized.lead]}>
|
||||
{resolvedIcon ? (
|
||||
<View style={[styles.iconSlot, sized.iconSlot]}>{resolvedIcon}</View>
|
||||
) : null}
|
||||
{hasDescription && typeof description !== "string" ? (
|
||||
<View style={styles.descriptionSlot}>{description}</View>
|
||||
) : null}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
{leadContent ? <View style={styles.leadText}>{leadContent}</View> : null}
|
||||
</View>
|
||||
{hasBody ? (
|
||||
<View style={resolvedIcon ? sized.indent : null}>
|
||||
{belowLead}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSizeStyles(size: AlertSize) {
|
||||
if (size === "xs") {
|
||||
return {
|
||||
container: styles.containerXs,
|
||||
text: styles.textXs,
|
||||
lead: styles.leadXs,
|
||||
iconSlot: styles.iconSlotXs,
|
||||
indent: styles.indentXs,
|
||||
};
|
||||
}
|
||||
if (size === "sm") {
|
||||
return {
|
||||
container: styles.containerSm,
|
||||
text: styles.textSm,
|
||||
lead: styles.leadSm,
|
||||
iconSlot: styles.iconSlotSm,
|
||||
indent: styles.indentSm,
|
||||
};
|
||||
}
|
||||
if (size === "lg") {
|
||||
return {
|
||||
container: styles.containerLg,
|
||||
text: styles.textLg,
|
||||
lead: styles.leadLg,
|
||||
iconSlot: styles.iconSlotLg,
|
||||
indent: styles.indentLg,
|
||||
};
|
||||
}
|
||||
return {
|
||||
container: styles.containerMd,
|
||||
text: styles.textMd,
|
||||
lead: styles.leadMd,
|
||||
iconSlot: styles.iconSlotMd,
|
||||
indent: styles.indentMd,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccentColor(
|
||||
variant: AlertVariant,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
@@ -80,43 +130,63 @@ function resolveAccentColor(
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
iconSlot: {
|
||||
paddingTop: 2,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
descriptionSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
return {
|
||||
container: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
containerXs: alert.xs.container,
|
||||
containerSm: alert.sm.container,
|
||||
containerMd: alert.md.container,
|
||||
containerLg: alert.lg.container,
|
||||
lead: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
},
|
||||
leadText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
leadXs: alert.xs.lead,
|
||||
leadSm: alert.sm.lead,
|
||||
leadMd: alert.md.lead,
|
||||
leadLg: alert.lg.lead,
|
||||
iconSlotXs: alert.xs.iconSlot,
|
||||
iconSlotSm: alert.sm.iconSlot,
|
||||
iconSlotMd: alert.md.iconSlot,
|
||||
iconSlotLg: alert.lg.iconSlot,
|
||||
indentXs: alert.xs.indent,
|
||||
indentSm: alert.sm.indent,
|
||||
indentMd: alert.md.indent,
|
||||
indentLg: alert.lg.indent,
|
||||
textXs: alert.xs.text,
|
||||
textSm: alert.sm.text,
|
||||
textMd: alert.md.text,
|
||||
textLg: alert.lg.text,
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
descriptionSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buttonControlHeight,
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
getControlInteractionPhase,
|
||||
} from "@/components/ui/control-geometry";
|
||||
@@ -11,6 +12,7 @@ const theme = {
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
"2xl": 16,
|
||||
full: 9999,
|
||||
},
|
||||
borderWidth: {
|
||||
@@ -30,10 +32,13 @@ const theme = {
|
||||
},
|
||||
spacing: {
|
||||
0: 0,
|
||||
0.5: 2,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
},
|
||||
} as unknown as Theme;
|
||||
|
||||
@@ -157,4 +162,41 @@ describe("control geometry", () => {
|
||||
geometry.buttonMd.paddingHorizontal,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives alert title and description one font size and a tight gap per size", () => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
expect(alert.xs.text).toEqual({ fontSize: 12 });
|
||||
expect(alert.sm.text).toEqual({ fontSize: 14 });
|
||||
expect(alert.md.text).toEqual(alert.sm.text);
|
||||
expect(alert.lg.text).toEqual(alert.sm.text);
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(alert[size].container.gap).toBeLessThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("starts alert text below the lead row at the lead text's left edge", () => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
|
||||
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
expect(alert.sm.container).toMatchObject({
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
});
|
||||
expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical);
|
||||
expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical);
|
||||
expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical);
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,77 @@ function fieldVerticalPadding(
|
||||
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
|
||||
* The icon and the first text sit in one centered lead row; everything below is indented
|
||||
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
|
||||
* description use one font size; only the container gap separates them.
|
||||
*/
|
||||
function createAlertGeometry(theme: Theme) {
|
||||
function alertSize(input: {
|
||||
size: ButtonControlSize;
|
||||
fontSize: number;
|
||||
paddingVertical: number;
|
||||
paddingHorizontal: number;
|
||||
borderRadius: number;
|
||||
iconGap: number;
|
||||
textGap: number;
|
||||
}) {
|
||||
const iconSize = buttonIconSize[input.size];
|
||||
return {
|
||||
container: {
|
||||
paddingVertical: input.paddingVertical,
|
||||
paddingHorizontal: input.paddingHorizontal,
|
||||
borderRadius: input.borderRadius,
|
||||
gap: input.textGap,
|
||||
},
|
||||
lead: { gap: input.iconGap },
|
||||
iconSlot: { width: iconSize },
|
||||
indent: { marginLeft: iconSize + input.iconGap },
|
||||
text: { fontSize: input.fontSize },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
xs: alertSize({
|
||||
size: "xs",
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
iconGap: theme.spacing[2],
|
||||
textGap: 0,
|
||||
}),
|
||||
sm: alertSize({
|
||||
size: "sm",
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
iconGap: theme.spacing[3],
|
||||
textGap: theme.spacing[0.5],
|
||||
}),
|
||||
md: alertSize({
|
||||
size: "md",
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
iconGap: theme.spacing[3],
|
||||
textGap: theme.spacing[0.5],
|
||||
}),
|
||||
lg: alertSize({
|
||||
size: "lg",
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[8],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
iconGap: theme.spacing[4],
|
||||
textGap: theme.spacing[1],
|
||||
}),
|
||||
} satisfies Record<ButtonControlSize, unknown>;
|
||||
}
|
||||
|
||||
export function getControlInteractionPhase(
|
||||
state: ControlInteractionState,
|
||||
): ControlInteractionPhase {
|
||||
@@ -147,6 +218,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextMdLineHeight,
|
||||
};
|
||||
const alert = createAlertGeometry(theme);
|
||||
const switchControl = {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||
@@ -156,22 +228,22 @@ export function createControlGeometry(theme: Theme) {
|
||||
buttonXs: {
|
||||
minHeight: buttonControlHeight.xs,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonSm: {
|
||||
minHeight: buttonControlHeight.sm,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonMd: {
|
||||
minHeight: buttonControlHeight.md,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonLg: {
|
||||
minHeight: buttonControlHeight.lg,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -179,6 +251,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
buttonTextXs: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
alert,
|
||||
formTextInputSm: {
|
||||
...fieldControlSm,
|
||||
...fieldTextSm,
|
||||
@@ -233,17 +306,17 @@ export function createControlGeometry(theme: Theme) {
|
||||
segmentedSegmentXs: {
|
||||
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
type ButtonControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"> {
|
||||
children?: ReactNode;
|
||||
chevron?: ReactNode | null;
|
||||
}
|
||||
|
||||
const chevronColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children" | "style"> {
|
||||
/** The selected option's label. */
|
||||
children: string;
|
||||
/** Icon or swatch before the label. */
|
||||
leading?: ReactNode;
|
||||
size?: ButtonControlSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* A select-style menu trigger: the selected value plus a chevron, drawn with the
|
||||
* outline Button's chrome and size scale so a dropdown and a button in the same
|
||||
* row share one geometry. Callers pick a size; they don't style it.
|
||||
*/
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
chevron,
|
||||
leading,
|
||||
size = "sm",
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownTriggerProps): ReactElement {
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
} else if (size === "md") {
|
||||
sizeStyle = styles.md;
|
||||
} else if (size === "lg") {
|
||||
sizeStyle = styles.lg;
|
||||
} else {
|
||||
sizeStyle = styles.sm;
|
||||
}
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed }: { pressed: boolean }) => [
|
||||
styles.base,
|
||||
sizeStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
],
|
||||
[disabled, sizeStyle],
|
||||
);
|
||||
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
|
||||
|
||||
return (
|
||||
<DropdownMenuTrigger {...props}>
|
||||
<View style={styles.row}>
|
||||
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
|
||||
{leading}
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{children}
|
||||
{chevron !== null &&
|
||||
(chevron ?? (
|
||||
<View style={styles.chevronContainer}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
))}
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
chevronContainer: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
base: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
xs: geometry.buttonXs,
|
||||
sm: geometry.buttonSm,
|
||||
md: geometry.buttonMd,
|
||||
lg: geometry.buttonLg,
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
disabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
...geometry.buttonText,
|
||||
},
|
||||
labelXs: geometry.buttonTextXs,
|
||||
chevron: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,15 +32,32 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseColor(value: string): { rgb: string; alpha: number } {
|
||||
const channels = value.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 };
|
||||
}
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each(["success", "warning", "error", "muted"] as const)(
|
||||
"uses the semantic badge shell for the %s variant",
|
||||
it("uses the neutral badge shell for the muted variant", () => {
|
||||
const style = getComputedStyle(mountBadge("muted"));
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
});
|
||||
|
||||
it.each(["success", "warning", "error"] as const)(
|
||||
"fills the %s variant with a translucent tint of its own status color",
|
||||
(variant) => {
|
||||
const badge = mountBadge(variant);
|
||||
const style = getComputedStyle(badge);
|
||||
const text = badge.lastElementChild;
|
||||
if (!(text instanceof HTMLElement)) {
|
||||
throw new Error("StatusBadge did not render its label");
|
||||
}
|
||||
const fill = parseColor(getComputedStyle(badge).backgroundColor);
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb);
|
||||
expect(fill.alpha).toBeGreaterThan(0);
|
||||
expect(fill.alpha).toBeLessThan(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ interface StatusBadgeProps {
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
|
||||
const pillStyle = useMemo(
|
||||
() => [
|
||||
styles.pill,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "warning" && styles.pillWarning,
|
||||
variant === "error" && styles.pillError,
|
||||
],
|
||||
[variant],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.pillText,
|
||||
@@ -22,7 +31,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.pill}>
|
||||
<View style={pillStyle}>
|
||||
{leading}
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</View>
|
||||
@@ -41,6 +50,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.statusSuccessTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillWarning: {
|
||||
backgroundColor: theme.colors.statusWarningTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: theme.colors.statusDangerTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function BrowserDataSection() {
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isClearing}
|
||||
disabled={isClearing}
|
||||
|
||||
@@ -131,6 +131,7 @@ export function DesktopNotificationsSection() {
|
||||
</View>
|
||||
{testNotificationState.status === "success" ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="success"
|
||||
title={t("settings.notifications.sentTitle")}
|
||||
description={t("settings.notifications.sentDescription")}
|
||||
@@ -139,6 +140,7 @@ export function DesktopNotificationsSection() {
|
||||
) : null}
|
||||
{testNotificationState.status === "error" ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.notifications.sendFailedTitle")}
|
||||
description={testNotificationState.message}
|
||||
|
||||
@@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert variant="error" description={message}>
|
||||
<Alert size="sm" variant="error" description={message}>
|
||||
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("pairing.device.retry")}
|
||||
</Button>
|
||||
@@ -197,9 +197,11 @@ function RelayConsent(props: PairDeviceBodyProps) {
|
||||
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
||||
/>
|
||||
</View>
|
||||
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
|
||||
{props.enableError ? (
|
||||
<Alert size="sm" variant="error" description={props.enableError.message} />
|
||||
) : null}
|
||||
{!props.canConfigureRelay ? (
|
||||
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
|
||||
<Alert size="sm" variant="warning" description={t("pairing.device.updateRequired")} />
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
||||
@@ -262,7 +264,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
||||
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
|
||||
<Alert size="sm" variant="warning" description={t("pairing.device.securityWarning")} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
function connectionListen(connection: HostConnection): string | null {
|
||||
switch (connection.type) {
|
||||
case "directTcp":
|
||||
return connection.endpoint;
|
||||
case "directSocket":
|
||||
return `unix://${connection.path}`;
|
||||
case "directPipe":
|
||||
return `pipe://${connection.path}`;
|
||||
case "remoteSsh":
|
||||
case "relay":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop main checks that this target matches the live desktop-managed daemon.
|
||||
// A plain browser has no bridge and falls back to its saved host password.
|
||||
export async function readDesktopManagedLocalCredential(
|
||||
connection: HostConnection,
|
||||
invoke: (listen: string) => Promise<unknown> = async (listen) =>
|
||||
getDesktopHost()?.invoke?.("desktop_local_credential", { listen }),
|
||||
): Promise<string | undefined> {
|
||||
const listen = connectionListen(connection);
|
||||
if (!listen) return undefined;
|
||||
const credential = await invoke(listen);
|
||||
return typeof credential === "string" ? credential : undefined;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
function makeHost(): HostProfile {
|
||||
return {
|
||||
serverId: "srv-secret",
|
||||
password: "tcp-password",
|
||||
label: "Secret host",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
@@ -23,7 +24,6 @@ function makeHost(): HostProfile {
|
||||
type: "directTcp",
|
||||
endpoint: "secret.example.test:6767",
|
||||
useTls: true,
|
||||
password: "tcp-password",
|
||||
},
|
||||
{
|
||||
id: "relay:relay.secret.test:443",
|
||||
|
||||
@@ -125,11 +125,11 @@ export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]):
|
||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (host.password) values.add(host.password);
|
||||
for (const connection of host.connections) {
|
||||
values.add(connection.id);
|
||||
if (connection.type === "directTcp") {
|
||||
values.add(connection.endpoint);
|
||||
if (connection.password) values.add(connection.password);
|
||||
} else if (connection.type === "relay") {
|
||||
values.add(connection.relayEndpoint);
|
||||
values.add(connection.daemonPublicKeyB64);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
@@ -197,6 +198,51 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the checkout's cached draft slash commands when its branch changes", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutStatusQueryKey(serverId, cwd),
|
||||
checkoutStatus({ currentBranch: "chore/build-paseo" }),
|
||||
);
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
const otherCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus());
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -67,7 +68,13 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||
const previousStatus = queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, payload.cwd),
|
||||
);
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||
if (previousStatus?.currentBranch !== payload.currentBranch) {
|
||||
void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
|
||||
@@ -38,9 +38,10 @@ describe("agent command query keys", () => {
|
||||
"agentCommands",
|
||||
"server-1",
|
||||
"draft",
|
||||
"codex",
|
||||
"cwd",
|
||||
"/repo",
|
||||
"provider",
|
||||
"codex",
|
||||
"mode",
|
||||
null,
|
||||
"model",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -24,17 +25,24 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId:
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
|
||||
function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) {
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(input.cwd),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function draftAgentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
draftConfig: AgentCommandsDraftConfig;
|
||||
}) {
|
||||
const { draftConfig } = input;
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
|
||||
"provider",
|
||||
draftConfig.provider,
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(draftConfig.cwd),
|
||||
"mode",
|
||||
draftConfig.modeId ?? null,
|
||||
"model",
|
||||
@@ -46,6 +54,16 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// Draft commands include project skills read from the checkout. When the checkout moves to
|
||||
// another branch the cached list describes files that are gone, so it is dropped rather than
|
||||
// shown while a refetch runs.
|
||||
export function resetDraftAgentCommandsForCheckout(
|
||||
queryClient: QueryClient,
|
||||
input: { serverId: string; cwd: string },
|
||||
): Promise<void> {
|
||||
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
}
|
||||
|
||||
export function agentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostConfirmation } from "@/runtime/host-runtime";
|
||||
|
||||
/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */
|
||||
export function HostConfirmationSheet() {
|
||||
const { t } = useTranslation();
|
||||
const { pending, answer } = useHostConfirmation();
|
||||
|
||||
const cancel = useCallback(() => answer(false), [answer]);
|
||||
const connect = useCallback(() => answer(true), [answer]);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.hostConfirmation.title") }), [t]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-confirmation">
|
||||
<Text style={styles.confirmText}>
|
||||
{pending.kind === "changedConnection"
|
||||
? t("pairing.hostConfirmation.descriptionChanged")
|
||||
: t("pairing.hostConfirmation.description")}
|
||||
</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.hostLabel")}
|
||||
value={pending.serverId}
|
||||
testID="host-confirmation-server-id"
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.fingerprintLabel")}
|
||||
value={pending.keyFingerprint}
|
||||
testID="host-confirmation-fingerprint"
|
||||
showBorder
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.relayLabel")}
|
||||
value={pending.relayEndpoint}
|
||||
testID="host-confirmation-relay"
|
||||
showBorder
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={cancel}
|
||||
testID="host-confirmation-cancel"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={connect}
|
||||
testID="host-confirmation-connect"
|
||||
>
|
||||
{t("pairing.hostConfirmation.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
showBorder = false,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
showBorder?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
<Text style={settingsStyles.rowHint} selectable testID={testID}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Same shape as the remove-host and remove-connection confirm sheets in
|
||||
// screens/settings/host-page.tsx.
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
confirmText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
confirmActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
const FLEX_1_STYLE = { flex: 1 };
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime";
|
||||
import { openPairScan, type PairScanModel } from "./pair-scan-model";
|
||||
|
||||
const LINK = "https://app.paseo.sh/#offer=abc";
|
||||
const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) };
|
||||
|
||||
/** The host runtime's side of a scan: each import resolves with the next queued outcome. */
|
||||
function createScanner(outcomes: Array<ConnectionLinkImport | Error>) {
|
||||
const imports: string[] = [];
|
||||
const connected: string[] = [];
|
||||
const scan = openPairScan({
|
||||
importConnectionLink: async (link) => {
|
||||
imports.push(link);
|
||||
const outcome = outcomes.shift();
|
||||
if (!outcome) throw new Error("No outcome queued for this import");
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome;
|
||||
},
|
||||
onConnected: (serverId) => connected.push(serverId),
|
||||
});
|
||||
return { scan, imports, connected };
|
||||
}
|
||||
|
||||
async function settled(scan: PairScanModel): Promise<void> {
|
||||
await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing"));
|
||||
}
|
||||
|
||||
describe("openPairScan", () => {
|
||||
it("stops scanning after Cancel until the user scans again", async () => {
|
||||
const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]);
|
||||
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
|
||||
// The code is still in front of the camera.
|
||||
scan.scan(LINK);
|
||||
scan.scan(LINK);
|
||||
await Promise.resolve();
|
||||
expect(imports).toEqual([LINK]);
|
||||
expect(scan.getState()).toEqual({ status: "stopped", error: null });
|
||||
|
||||
scan.scanAgain();
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
expect(imports).toEqual([LINK, LINK]);
|
||||
});
|
||||
|
||||
it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => {
|
||||
const { scan, imports, connected } = createScanner([
|
||||
{ status: "connected", serverId: "srv_scan" },
|
||||
]);
|
||||
|
||||
scan.scan("https://example.com/not-a-pairing-link");
|
||||
expect(scan.getState()).toEqual({ status: "scanning" });
|
||||
|
||||
scan.scan(LINK);
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
|
||||
expect(imports).toEqual([LINK]);
|
||||
expect(connected).toEqual(["srv_scan"]);
|
||||
scan.scan(LINK);
|
||||
expect(imports).toEqual([LINK]);
|
||||
});
|
||||
|
||||
it("ends the attempt when the password prompt closes", async () => {
|
||||
const { scan, imports } = createScanner([
|
||||
{ status: "password_required", link: LINK, pairing: PAIRING },
|
||||
]);
|
||||
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
expect(scan.getState()).toEqual({
|
||||
status: "passwordRequired",
|
||||
passwordRequired: { link: LINK, pairing: PAIRING },
|
||||
});
|
||||
scan.scan(LINK);
|
||||
|
||||
scan.closePassword();
|
||||
scan.scan(LINK);
|
||||
|
||||
expect(scan.getState()).toEqual({ status: "stopped", error: null });
|
||||
expect(imports).toEqual([LINK]);
|
||||
});
|
||||
|
||||
it("stops with the error when an attempt fails", async () => {
|
||||
const { scan, imports } = createScanner([new Error("Server disconnected")]);
|
||||
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
scan.scan(LINK);
|
||||
|
||||
expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" });
|
||||
expect(imports).toEqual([LINK]);
|
||||
});
|
||||
|
||||
it("does not navigate for an attempt that finishes after the screen closed", async () => {
|
||||
let finish: (outcome: ConnectionLinkImport) => void = () => {};
|
||||
const connected: string[] = [];
|
||||
const scan = openPairScan({
|
||||
importConnectionLink: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
onConnected: (serverId) => connected.push(serverId),
|
||||
});
|
||||
|
||||
scan.scan(LINK);
|
||||
scan.close();
|
||||
finish({ status: "connected", serverId: "srv_scan" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(connected).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
|
||||
/**
|
||||
* One visit to the QR scan screen. The camera reports every frame that shows
|
||||
* a code, so an attempt that ends (Cancel on the host confirmation, a closed
|
||||
* password prompt, or an error) stops scanning until the user taps scan again.
|
||||
* Without that, the code still in front of the camera starts a new attempt on
|
||||
* the next frame.
|
||||
*/
|
||||
export type PairScanState =
|
||||
| { status: "scanning" }
|
||||
| { status: "pairing" }
|
||||
| { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing }
|
||||
| { status: "stopped"; error: string | null }
|
||||
| { status: "connected" };
|
||||
|
||||
export interface PairScanModel {
|
||||
getState: () => PairScanState;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** Data from a QR code the camera saw. Only a scanning session starts an attempt. */
|
||||
scan: (data: string) => void;
|
||||
/** The user asked to scan again after an attempt ended. */
|
||||
scanAgain: () => void;
|
||||
/** The user closed the password prompt, which ends the attempt. */
|
||||
closePassword: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface PairScanDependencies {
|
||||
importConnectionLink: (link: string) => Promise<ConnectionLinkImport>;
|
||||
onConnected: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export function openPairScan(deps: PairScanDependencies): PairScanModel {
|
||||
let state: PairScanState = { status: "scanning" };
|
||||
let closed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(next: PairScanState): void {
|
||||
if (closed) return;
|
||||
state = next;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
async function pair(link: string): Promise<void> {
|
||||
publish({ status: "pairing" });
|
||||
try {
|
||||
const outcome = await deps.importConnectionLink(link);
|
||||
if (closed) return;
|
||||
if (outcome.status === "connected") {
|
||||
publish({ status: "connected" });
|
||||
deps.onConnected(outcome.serverId);
|
||||
return;
|
||||
}
|
||||
if (outcome.status === "password_required") {
|
||||
const { link: passwordLink, pairing } = outcome;
|
||||
publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } });
|
||||
return;
|
||||
}
|
||||
publish({ status: "stopped", error: null });
|
||||
} catch (error) {
|
||||
publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
scan(data) {
|
||||
if (state.status !== "scanning") return;
|
||||
const link = pairingLinkFromScan(data);
|
||||
if (!link) return;
|
||||
void pair(link);
|
||||
},
|
||||
scanAgain() {
|
||||
if (state.status !== "stopped") return;
|
||||
publish({ status: "scanning" });
|
||||
},
|
||||
closePassword() {
|
||||
if (state.status !== "passwordRequired") return;
|
||||
publish({ status: "stopped", error: null });
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pairingLinkFromScan(data: string): string | null {
|
||||
const raw = data.trim();
|
||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1613,6 +1613,21 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "كلمة المرور لـ {{host}}",
|
||||
label: "كلمة مرور المضيف",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "الاتصال بهذا المضيف؟",
|
||||
description:
|
||||
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
descriptionChanged:
|
||||
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
hostLabel: "المضيف",
|
||||
fingerprintLabel: "بصمة المفتاح",
|
||||
relayLabel: "المُرحّل",
|
||||
connect: "اتصال",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "إضافة اتصال",
|
||||
direct: {
|
||||
@@ -1949,8 +1964,11 @@ export const ar: TranslationResources = {
|
||||
groupInfo: "حول{{title}}",
|
||||
sections: {
|
||||
general: "عام",
|
||||
chat: "الدردشة",
|
||||
appearance: "مظهر",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "الشريط الجانبي",
|
||||
terminal: "الطرفية",
|
||||
browser: "المتصفح",
|
||||
editor: "المحرر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
@@ -2009,6 +2027,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
sending: "الإرسال",
|
||||
browserData: {
|
||||
title: "بيانات المتصفح",
|
||||
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||
@@ -2036,8 +2055,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "عناوين URL للخدمة",
|
||||
description: "مكان فتح عناوين URL من تشغيل البرامج النصية",
|
||||
options: {
|
||||
ask: "بسأل",
|
||||
inApp: "في Paseo",
|
||||
@@ -2056,7 +2073,6 @@ export const ar: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
|
||||
options: {
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
@@ -2294,6 +2310,9 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.",
|
||||
},
|
||||
appearance: {
|
||||
title: "المظهر",
|
||||
name: {
|
||||
|
||||
@@ -1638,6 +1638,21 @@ export const en = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Password for {{host}}",
|
||||
label: "Host password",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Connect to this host?",
|
||||
description:
|
||||
"This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
|
||||
descriptionChanged:
|
||||
"This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Key fingerprint",
|
||||
relayLabel: "Relay",
|
||||
connect: "Connect",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Add connection",
|
||||
direct: {
|
||||
@@ -1974,9 +1989,12 @@ export const en = {
|
||||
groupInfo: "About {{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
layout: "Layout",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
notifications: "Notifications",
|
||||
@@ -1994,29 +2012,32 @@ export const en = {
|
||||
},
|
||||
sources: {
|
||||
explorerFiles: {
|
||||
label: "Selecting a file in Explorer",
|
||||
label: "Clicking a file in the Explorer sidebar",
|
||||
description: "Open files selected in the Explorer sidebar beside your work",
|
||||
},
|
||||
diffs: {
|
||||
label: "Opening a diff",
|
||||
label: "Clicking a change in the Explorer sidebar or a chat",
|
||||
description: "Open diffs from Explorer and agent conversations beside your work",
|
||||
},
|
||||
chatFiles: {
|
||||
label: "Opening a file from an agent chat",
|
||||
label: "Clicking a file in an agent chat",
|
||||
description: "Open file links and tool-call files beside the conversation",
|
||||
},
|
||||
diffFiles: {
|
||||
label: "Opening a file from Changes",
|
||||
label: "Clicking a file in a diff",
|
||||
description: "Open source files selected from a diff beside it",
|
||||
},
|
||||
subagents: {
|
||||
label: "Opening a subagent",
|
||||
label: "Clicking a subagent in an agent chat",
|
||||
description: "Open subagents beside their parent agent",
|
||||
},
|
||||
pullRequests: {
|
||||
label: "Opening a pull request from Changes",
|
||||
label: "Clicking a pull request in the Explorer sidebar",
|
||||
description: "Open pull request details beside Changes",
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Clicking a script's service URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2130,6 +2151,7 @@ export const en = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Sending",
|
||||
browserData: {
|
||||
title: "Browser data",
|
||||
siteData: "Cookies and site data",
|
||||
@@ -2156,8 +2178,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Service URLs",
|
||||
description: "Where to open URLs from running scripts",
|
||||
options: {
|
||||
ask: "Ask",
|
||||
inApp: "In Paseo",
|
||||
@@ -2176,7 +2196,6 @@ export const en = {
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
accessibilityLabel: "Select tool call display ({{value}})",
|
||||
options: {
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
@@ -2413,6 +2432,9 @@ export const en = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "Remove this host and add it again with the password this daemon asks for.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
name: {
|
||||
|
||||
@@ -1659,6 +1659,21 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Contraseña de {{host}}",
|
||||
label: "Contraseña del host",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "¿Conectar con este host?",
|
||||
description:
|
||||
"Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
|
||||
descriptionChanged:
|
||||
"Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Huella de la clave",
|
||||
relayLabel: "Relay",
|
||||
connect: "Conectar",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Agregar conexión",
|
||||
direct: {
|
||||
@@ -1997,8 +2012,11 @@ export const es: TranslationResources = {
|
||||
groupInfo: "Acerca de{{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Apariencia",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
@@ -2057,6 +2075,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Envío",
|
||||
browserData: {
|
||||
title: "Datos del navegador",
|
||||
siteData: "Cookies y datos de sitios",
|
||||
@@ -2087,8 +2106,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de servicio",
|
||||
description: "Dónde abrir URL desde scripts en ejecución",
|
||||
options: {
|
||||
ask: "Preguntar",
|
||||
inApp: "EnPaseo",
|
||||
@@ -2108,7 +2125,6 @@ export const es: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
@@ -2348,6 +2364,9 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "Elimina este host y vuelve a añadirlo con la contraseña que pide este daemon.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Apariencia",
|
||||
name: {
|
||||
|
||||
@@ -1663,6 +1663,21 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Mot de passe pour {{host}}",
|
||||
label: "Mot de passe de l’hôte",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Se connecter à cet hôte ?",
|
||||
description:
|
||||
"Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
|
||||
descriptionChanged:
|
||||
"Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
|
||||
hostLabel: "Hôte",
|
||||
fingerprintLabel: "Empreinte de la clé",
|
||||
relayLabel: "Relais",
|
||||
connect: "Se connecter",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Ajouter une connexion",
|
||||
direct: {
|
||||
@@ -2002,8 +2017,11 @@ export const fr: TranslationResources = {
|
||||
groupInfo: "À propos de{{title}}",
|
||||
sections: {
|
||||
general: "Général",
|
||||
chat: "Discussion",
|
||||
appearance: "Apparence",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barre latérale",
|
||||
terminal: "Terminal",
|
||||
browser: "Navigateur",
|
||||
editor: "Éditeur",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
@@ -2062,6 +2080,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
sending: "Envoi",
|
||||
browserData: {
|
||||
title: "Données du navigateur",
|
||||
siteData: "Cookies et données des sites",
|
||||
@@ -2091,8 +2110,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de services",
|
||||
description: "Où ouvrir les URL à partir de scripts en cours d'exécution",
|
||||
options: {
|
||||
ask: "Demander",
|
||||
inApp: "DansPaseo",
|
||||
@@ -2111,7 +2128,6 @@ export const fr: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})",
|
||||
options: {
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
@@ -2352,6 +2368,10 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance:
|
||||
"Supprimez cet hôte puis ajoutez-le à nouveau avec le mot de passe demandé par ce daemon.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Apparence",
|
||||
name: {
|
||||
|
||||
@@ -1630,6 +1630,21 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "{{host}} のパスワード",
|
||||
label: "ホストのパスワード",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "このホストに接続しますか?",
|
||||
description:
|
||||
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
descriptionChanged:
|
||||
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
hostLabel: "ホスト",
|
||||
fingerprintLabel: "鍵のフィンガープリント",
|
||||
relayLabel: "リレー",
|
||||
connect: "接続",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "接続を追加",
|
||||
direct: {
|
||||
@@ -1969,8 +1984,11 @@ export const ja: TranslationResources = {
|
||||
groupInfo: "{{title}}について",
|
||||
sections: {
|
||||
general: "一般",
|
||||
chat: "チャット",
|
||||
appearance: "外観",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "サイドバー",
|
||||
terminal: "ターミナル",
|
||||
browser: "ブラウザ",
|
||||
editor: "エディター",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
@@ -2029,6 +2047,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
sending: "送信",
|
||||
browserData: {
|
||||
title: "ブラウザーデータ",
|
||||
siteData: "Cookie とサイトデータ",
|
||||
@@ -2055,8 +2074,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "サービスURL",
|
||||
description: "実行中のスクリプトからURLを開く場所",
|
||||
options: {
|
||||
ask: "確認する",
|
||||
inApp: "Paseoで",
|
||||
@@ -2075,7 +2092,6 @@ export const ja: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
accessibilityLabel: "ツール呼び出しの表示を選択({{value}})",
|
||||
options: {
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
@@ -2315,6 +2331,10 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance:
|
||||
"このホストを削除し、このデーモンが求めるパスワードを入力して追加し直してください。",
|
||||
},
|
||||
appearance: {
|
||||
title: "外観",
|
||||
name: {
|
||||
|
||||
@@ -1623,6 +1623,21 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "{{host}}의 비밀번호",
|
||||
label: "호스트 비밀번호",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "이 호스트에 연결할까요?",
|
||||
description:
|
||||
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
descriptionChanged:
|
||||
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
hostLabel: "호스트",
|
||||
fingerprintLabel: "키 지문",
|
||||
relayLabel: "릴레이",
|
||||
connect: "연결",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "연결 추가",
|
||||
direct: {
|
||||
@@ -1959,8 +1974,11 @@ export const ko: TranslationResources = {
|
||||
groupInfo: "{{title}} 정보",
|
||||
sections: {
|
||||
general: "일반",
|
||||
chat: "채팅",
|
||||
appearance: "모양",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "사이드바",
|
||||
terminal: "터미널",
|
||||
browser: "브라우저",
|
||||
editor: "편집기",
|
||||
shortcuts: "단축키",
|
||||
integrations: "통합",
|
||||
@@ -2019,6 +2037,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
sending: "전송",
|
||||
browserData: {
|
||||
title: "브라우저 데이터",
|
||||
siteData: "쿠키 및 사이트 데이터",
|
||||
@@ -2047,8 +2066,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "서비스 URL",
|
||||
description: "실행 중인 스크립트의 URL을 열 위치",
|
||||
options: {
|
||||
ask: "물어보기",
|
||||
inApp: "Paseo에서",
|
||||
@@ -2067,7 +2084,6 @@ export const ko: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
accessibilityLabel: "도구 호출 표시 선택({{value}})",
|
||||
options: {
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
@@ -2304,6 +2320,9 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "이 호스트를 제거한 뒤 이 데몬이 요구하는 비밀번호로 다시 추가하세요.",
|
||||
},
|
||||
appearance: {
|
||||
title: "모양",
|
||||
name: {
|
||||
|
||||
@@ -1644,6 +1644,21 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Senha de {{host}}",
|
||||
label: "Senha do host",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Conectar a este host?",
|
||||
description:
|
||||
"Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
|
||||
descriptionChanged:
|
||||
"Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Impressão da chave",
|
||||
relayLabel: "Relay",
|
||||
connect: "Conectar",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Adicionar conexão",
|
||||
direct: {
|
||||
@@ -1982,8 +1997,11 @@ export const ptBR: TranslationResources = {
|
||||
groupInfo: "Sobre {{title}}",
|
||||
sections: {
|
||||
general: "Geral",
|
||||
chat: "Chat",
|
||||
appearance: "Aparência",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
@@ -2042,6 +2060,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
sending: "Envio",
|
||||
browserData: {
|
||||
title: "Dados do navegador",
|
||||
siteData: "Cookies e dados de sites",
|
||||
@@ -2070,8 +2089,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URLs de serviço",
|
||||
description: "Onde abrir URLs de scripts em execução",
|
||||
options: {
|
||||
ask: "Perguntar",
|
||||
inApp: "No Paseo",
|
||||
@@ -2091,7 +2108,6 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
@@ -2330,6 +2346,9 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "Remova este host e adicione-o novamente com a senha que este daemon pede.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Aparência",
|
||||
name: {
|
||||
|
||||
@@ -1642,6 +1642,21 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Пароль для {{host}}",
|
||||
label: "Пароль хоста",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Подключиться к этому хосту?",
|
||||
description:
|
||||
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
descriptionChanged:
|
||||
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
hostLabel: "Хост",
|
||||
fingerprintLabel: "Отпечаток ключа",
|
||||
relayLabel: "Реле",
|
||||
connect: "Подключить",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Добавить подключение",
|
||||
direct: {
|
||||
@@ -1982,8 +1997,11 @@ export const ru: TranslationResources = {
|
||||
groupInfo: "О разделе «{{title}}»",
|
||||
sections: {
|
||||
general: "Основные",
|
||||
chat: "Чат",
|
||||
appearance: "Оформление",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Боковая панель",
|
||||
terminal: "Терминал",
|
||||
browser: "Браузер",
|
||||
editor: "Редактор",
|
||||
shortcuts: "Сочетания клавиш",
|
||||
integrations: "Интеграции",
|
||||
@@ -2042,6 +2060,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Основные",
|
||||
sending: "Отправка",
|
||||
browserData: {
|
||||
title: "Данные браузера",
|
||||
siteData: "Файлы cookie и данные сайтов",
|
||||
@@ -2071,8 +2090,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL-адреса сервисов",
|
||||
description: "Где открывать URL-адреса запущенных скриптов",
|
||||
options: {
|
||||
ask: "Спрашивать",
|
||||
inApp: "В Paseo",
|
||||
@@ -2092,7 +2109,6 @@ export const ru: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})",
|
||||
options: {
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
@@ -2334,6 +2350,10 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance:
|
||||
"Удалите этот хост и добавьте его снова с паролем, который запрашивает этот демон.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Оформление",
|
||||
name: {
|
||||
|
||||
@@ -1596,6 +1596,20 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "{{host}} 的密码",
|
||||
label: "主机密码",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "连接到此主机?",
|
||||
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
descriptionChanged:
|
||||
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
hostLabel: "主机",
|
||||
fingerprintLabel: "密钥指纹",
|
||||
relayLabel: "中继",
|
||||
connect: "连接",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "添加连接",
|
||||
direct: {
|
||||
@@ -1928,8 +1942,11 @@ export const zhCN: TranslationResources = {
|
||||
groupInfo: "关于 {{title}}",
|
||||
sections: {
|
||||
general: "通用",
|
||||
chat: "聊天",
|
||||
appearance: "外观",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "侧边栏",
|
||||
terminal: "终端",
|
||||
browser: "浏览器",
|
||||
editor: "编辑器",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
@@ -1987,6 +2004,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
sending: "发送",
|
||||
browserData: {
|
||||
title: "浏览器数据",
|
||||
siteData: "Cookie 和网站数据",
|
||||
@@ -2012,8 +2030,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "服务 URL",
|
||||
description: "运行脚本中的 URL 打开位置",
|
||||
options: {
|
||||
ask: "询问",
|
||||
inApp: "在 Paseo 中",
|
||||
@@ -2032,7 +2048,6 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
accessibilityLabel: "选择工具调用显示方式({{value}})",
|
||||
options: {
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
@@ -2268,6 +2283,9 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "移除此主机,然后使用此守护进程要求的密码重新添加。",
|
||||
},
|
||||
appearance: {
|
||||
title: "外观",
|
||||
name: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
|
||||
|
||||
describe("startHostRuntimeBootstrap", () => {
|
||||
it("boots the host registry and starts the managed-daemon decision as one operation", async () => {
|
||||
it("boots the host registry and starts the managed-daemon decision independently", async () => {
|
||||
const events: string[] = [];
|
||||
const shouldStartDaemon = async () => true;
|
||||
const store = {
|
||||
@@ -40,9 +40,10 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
|
||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||
expect(await receivedDecisions[0]).toBe(true);
|
||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||
});
|
||||
|
||||
it("waits for the host registry to load before evaluating managed-daemon startup", async () => {
|
||||
it("loads the registry before evaluating managed-daemon startup", async () => {
|
||||
const events: string[] = [];
|
||||
let resolveBoot!: () => void;
|
||||
const booted = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -145,6 +145,7 @@ function BlockedSetupNotice({
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("workspace.setup.blocked.title")}
|
||||
description={t("workspace.setup.blocked.description", {
|
||||
@@ -158,7 +159,7 @@ function BlockedSetupNotice({
|
||||
</Button>
|
||||
) : null}
|
||||
</Alert>
|
||||
{runError ? <Alert variant="error" description={runError} /> : null}
|
||||
{runError ? <Alert size="sm" variant="error" description={runError} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,12 @@ function ProviderUsageBody({
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={providerUsageCopy.errorTitle}
|
||||
description={view.message}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{providerUsageCopy.retry}
|
||||
</Button>
|
||||
|
||||
@@ -325,6 +325,15 @@ describe("upsertDesktopDaemonConnection", () => {
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an already registered host without waiting for a listen address", async () => {
|
||||
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
|
||||
|
||||
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing listen address without upserting", async () => {
|
||||
const fake = createFakeStore();
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export async function upsertDesktopDaemonConnection(
|
||||
if (store.getHosts().some((host) => host.serverId === serverId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const listenAddress = daemon.listen?.trim() ?? "";
|
||||
if (!listenAddress) {
|
||||
return { ok: false, error: "Desktop daemon did not return a listen address." };
|
||||
|
||||
@@ -44,6 +44,7 @@ class FakeDirectoryClient {
|
||||
projectResult: ProjectListResult | null = null;
|
||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
@@ -82,6 +83,14 @@ class FakeDirectoryClient {
|
||||
return complete;
|
||||
}
|
||||
|
||||
holdProjectFetch(): (result: ProjectListResult) => void {
|
||||
let complete!: (result: ProjectListResult) => void;
|
||||
this.pendingProjectFetch = new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
this.fetchAgentsCalls += 1;
|
||||
this.lastAgentOptions = options;
|
||||
@@ -116,6 +125,11 @@ class FakeDirectoryClient {
|
||||
async listProjects(options?: unknown): Promise<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
this.lastProjectOptions = options;
|
||||
if (this.pendingProjectFetch) {
|
||||
const pending = this.pendingProjectFetch;
|
||||
this.pendingProjectFetch = null;
|
||||
return pending;
|
||||
}
|
||||
if (this.projectResult) return this.projectResult;
|
||||
return {
|
||||
requestId: "projects",
|
||||
@@ -227,6 +241,25 @@ function createAgent(serverId: string, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
|
||||
return {
|
||||
id,
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Snapshot project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
@@ -1115,6 +1148,41 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
|
||||
const serverId = "superseded-workspace-baseline";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true },
|
||||
});
|
||||
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
|
||||
const release = client.holdWorkspaceFetch();
|
||||
const refresh = directory.refreshWorkspaces();
|
||||
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
|
||||
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
release({
|
||||
requestId: "old-empty-snapshot",
|
||||
entries: [],
|
||||
emptyProjects: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
});
|
||||
await rejected;
|
||||
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
|
||||
"retained",
|
||||
]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
@@ -1182,9 +1250,19 @@ describe("DirectorySync session readiness", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
|
||||
},
|
||||
});
|
||||
completeFetch({
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
entries: [
|
||||
createWorkspaceEntry("removed-workspace"),
|
||||
createWorkspaceEntry("updated-workspace"),
|
||||
],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "snapshot-project",
|
||||
@@ -1207,6 +1285,11 @@ describe("DirectorySync session readiness", () => {
|
||||
expect(projects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
expect(
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
|
||||
({ id, name }) => ({ id, name }),
|
||||
),
|
||||
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
@@ -1278,6 +1361,8 @@ function createSqliteCache() {
|
||||
return {
|
||||
cache,
|
||||
database,
|
||||
storage,
|
||||
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
|
||||
holdRead: (operation: () => Promise<void>) => {
|
||||
beforeRead = operation;
|
||||
},
|
||||
@@ -1473,3 +1558,198 @@ describe("DirectorySync workspace labels", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["before", "during", "before-metadata", "damaged"] as const)(
|
||||
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
|
||||
async (timing) => {
|
||||
const cacheFirst = timing === "before";
|
||||
const damaged = timing === "damaged";
|
||||
const serverId = `cache-route-overlap-${timing}`;
|
||||
serverIds.add(serverId);
|
||||
const { cache, database, reopen, storage } = createSqliteCache();
|
||||
cache.setHosts([serverId]);
|
||||
const projectEntry: ProjectListResult["projects"][number] = {
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
};
|
||||
const project = normalizeProjectDescriptor(projectEntry);
|
||||
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
|
||||
id,
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
}));
|
||||
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
|
||||
const cursor = { generation: "g", afterSeq: 7 };
|
||||
cache.replaceDirectoryBaseline(serverId, {
|
||||
agents: new Map(),
|
||||
projects: new Map([[project.projectId, project]]),
|
||||
workspaces: new Map(
|
||||
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
|
||||
),
|
||||
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
|
||||
});
|
||||
await cache.flush();
|
||||
if (damaged) {
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
|
||||
class UnchangedDirectoryClient extends FakeDirectoryClient {
|
||||
readonly workspaceSyncRequests: unknown[] = [];
|
||||
override async fetchWorkspaces(
|
||||
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
|
||||
): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
this.lastWorkspaceOptions = options;
|
||||
this.workspaceSyncRequests.push(options?.sync);
|
||||
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
|
||||
return {
|
||||
requestId: "workspaces",
|
||||
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
|
||||
emptyProjects: [],
|
||||
pageInfo: {
|
||||
hasMore: !changes && !options?.page?.cursor,
|
||||
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
|
||||
prevCursor: null,
|
||||
},
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: changes ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const client = new UnchangedDirectoryClient();
|
||||
const releaseAgents = client.holdAgentFetch();
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const projectResult: ProjectListResult = {
|
||||
requestId: "projects",
|
||||
projects: [projectEntry],
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
};
|
||||
client.projectResult = projectResult;
|
||||
const callbacks = {
|
||||
onAgentStoppedRunning: () => {},
|
||||
markAgentLoading: () => {},
|
||||
markAgentReady: () => {},
|
||||
markAgentError: () => {},
|
||||
};
|
||||
const directory = new DirectorySync(serverId, callbacks, cache);
|
||||
const publishServerInfo = () => {
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
|
||||
});
|
||||
};
|
||||
const initializeSession = (ready = true) => {
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
if (ready) publishServerInfo();
|
||||
};
|
||||
const connection = {
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online" as const,
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
};
|
||||
initializeSession(timing !== "before-metadata");
|
||||
directory.connectionChanged(connection);
|
||||
const visibleIds = () =>
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
|
||||
let restarted: DirectorySync | undefined;
|
||||
const restartedCache = reopen();
|
||||
try {
|
||||
if (cacheFirst) await directory.restoreCachedDirectory();
|
||||
directory.setAgentRouteDemand(["agent"]);
|
||||
if (timing === "before-metadata") {
|
||||
await directory.restoreCachedDirectory();
|
||||
publishServerInfo();
|
||||
}
|
||||
await expect.poll(() => client.listProjectsCalls).toBe(1);
|
||||
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
|
||||
directory.setDemand({}, true);
|
||||
await directory.restoreCachedDirectory();
|
||||
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
|
||||
|
||||
releaseProjects(projectResult);
|
||||
releaseAgents({
|
||||
requestId: "agents",
|
||||
entries: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: cacheFirst ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
});
|
||||
await directory.refreshDemand();
|
||||
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
|
||||
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
|
||||
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
|
||||
await cache.flush();
|
||||
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
|
||||
await directory.refreshAll();
|
||||
await cache.flush();
|
||||
directory.dispose();
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
restartedCache.setHosts([serverId]);
|
||||
const persisted = await restartedCache.readDirectory(serverId);
|
||||
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
|
||||
expect
|
||||
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
|
||||
.toEqual(["A", "B"]);
|
||||
initializeSession();
|
||||
restarted = new DirectorySync(serverId, callbacks, restartedCache);
|
||||
restarted.connectionChanged(connection);
|
||||
await restarted.refreshAll();
|
||||
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
|
||||
const beforeUpdate = visibleIds();
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
|
||||
});
|
||||
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
|
||||
});
|
||||
expect(visibleIds()).toEqual(["A", "B"]);
|
||||
} finally {
|
||||
directory.dispose();
|
||||
restarted?.dispose();
|
||||
await cache.flush();
|
||||
await restartedCache.flush();
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,6 +61,10 @@ interface AgentSnapshot {
|
||||
syncRemovals: Array<{ id: string; seq: number }>;
|
||||
}
|
||||
|
||||
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
|
||||
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
|
||||
}
|
||||
|
||||
interface AgentPageInfo {
|
||||
hasMore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
@@ -133,7 +137,7 @@ export class DirectorySync {
|
||||
AgentDirectoryDelta
|
||||
>();
|
||||
private readonly workspaceTransactions = new DirectoryTransactionOwner<
|
||||
WorkspaceDirectorySnapshot,
|
||||
WorkspaceRefreshSnapshot,
|
||||
WorkspaceDirectoryDelta
|
||||
>();
|
||||
private readonly agents: AgentDirectoryReplica;
|
||||
@@ -478,14 +482,20 @@ export class DirectorySync {
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) return;
|
||||
const { client, source } = onlineConnection;
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
|
||||
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
}));
|
||||
const transaction = this.workspaceTransactions.begin(source, () => {
|
||||
const baseline = this.workspaces.snapshot();
|
||||
return {
|
||||
workspaces: new Map(baseline.workspaces),
|
||||
projects: new Map(baseline.projects),
|
||||
// Cache hydration can advance the live replica while these requests await responses.
|
||||
// Every page must use the cursors belonging to the maps captured here.
|
||||
requestCursors: { ...this.cursors },
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
@@ -517,7 +527,7 @@ export class DirectorySync {
|
||||
private async fetchWorkspaceSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
@@ -527,7 +537,9 @@ export class DirectorySync {
|
||||
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
|
||||
...(supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
|
||||
: {}),
|
||||
};
|
||||
let payload: FetchWorkspacesPayload;
|
||||
if (subscribe) {
|
||||
@@ -667,7 +679,7 @@ export class DirectorySync {
|
||||
private assertWorkspaceTransactionCurrent(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
|
||||
@@ -724,11 +736,13 @@ export class DirectorySync {
|
||||
private async fetchProjectSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
const payload = await client.listProjects(
|
||||
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
|
||||
supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
|
||||
: undefined,
|
||||
);
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
|
||||
@@ -753,7 +767,7 @@ export class DirectorySync {
|
||||
private completeWorkspaceRefresh(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
hostHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
type HostProfile,
|
||||
} from "@/types/host-connection";
|
||||
|
||||
/**
|
||||
* The question shown before a pairing link adds a host that is not saved, or
|
||||
* changes the key, relay, or TLS setting of a saved one.
|
||||
*/
|
||||
export interface HostConfirmationRequest {
|
||||
/** Identifies this question, so an answer shown for it never answers a newer one. */
|
||||
id: number;
|
||||
serverId: string;
|
||||
/** Grouped daemon public key the user can compare with the one their daemon shows. */
|
||||
keyFingerprint: string;
|
||||
relayEndpoint: string;
|
||||
kind: "newHost" | "changedConnection";
|
||||
}
|
||||
|
||||
interface PendingConfirmation {
|
||||
request: HostConfirmationRequest;
|
||||
resolve: (approved: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the one host confirmation the app is waiting on. The host runtime asks
|
||||
* through `confirmLink`; the view reads `getPending` and replies with `answer`.
|
||||
* A question waits until it is answered, whether or not a view is showing it.
|
||||
*/
|
||||
export class HostConfirmations {
|
||||
private pending: PendingConfirmation | null = null;
|
||||
private nextRequestId = 1;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
getPending(): HostConfirmationRequest | null {
|
||||
return this.pending?.request ?? null;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Answers the request with `requestId`; an answer for any other request is ignored. */
|
||||
answer(requestId: number, approved: boolean): void {
|
||||
const current = this.pending;
|
||||
if (!current || current.request.id !== requestId) return;
|
||||
this.pending = null;
|
||||
this.emit();
|
||||
current.resolve(approved);
|
||||
}
|
||||
|
||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||
return this.ask({
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
});
|
||||
}
|
||||
|
||||
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||
// One question at a time: a newer one cancels the unanswered one.
|
||||
this.pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.pending = { request, resolve };
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the key material itself (not a re-hash) in groups of four. Keys longer
|
||||
* than 16 characters show only their first and last 8, so two keys that share
|
||||
* both ends show the same value.
|
||||
*/
|
||||
function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
|
||||
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
|
||||
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
|
||||
if (normalized.length <= 16) return group(normalized);
|
||||
return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppStateStatus } from "react-native";
|
||||
import { bindHostRuntimeAppState } from "@/navigation/host-runtime-bootstrap";
|
||||
import {
|
||||
bindHostRuntimeAppState,
|
||||
startHostRuntimeBootstrap,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import type {
|
||||
DaemonClient,
|
||||
ConnectionState,
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
@@ -26,11 +30,32 @@ import {
|
||||
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
const connection: HostConnection = {
|
||||
id: "direct:localhost:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "localhost:6767",
|
||||
};
|
||||
let token = "local-token";
|
||||
const invoke = async (listen: string) => {
|
||||
requests.push(listen);
|
||||
return token;
|
||||
};
|
||||
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("local-token");
|
||||
token = "rotated-token";
|
||||
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("rotated-token");
|
||||
expect(requests).toEqual(["localhost:6767", "localhost:6767"]);
|
||||
});
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
private error: string | null = null;
|
||||
authFailureReason: "password_required" | "incorrect_password" | null = null;
|
||||
private heartbeatRttMs: number | null = null;
|
||||
private latencyMeasurementFailure: Error | null = null;
|
||||
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
|
||||
@@ -381,6 +406,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
|
||||
return {
|
||||
serverId: input?.serverId ?? "srv_test",
|
||||
...(input?.password ? { password: input.password } : {}),
|
||||
label: input?.label ?? "test host",
|
||||
appearance: input?.appearance ?? defaultHostAppearance(),
|
||||
lifecycle: input?.lifecycle ?? {},
|
||||
@@ -471,6 +497,63 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers every host confirmation the store asks, the way the sheet does, and
|
||||
* records each request.
|
||||
*/
|
||||
function answerHostConfirmations(
|
||||
store: HostRuntimeStore,
|
||||
answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean),
|
||||
): HostConfirmationRequest[] {
|
||||
const requests: HostConfirmationRequest[] = [];
|
||||
store.subscribeHostConfirmation(() => {
|
||||
const pending = store.getPendingHostConfirmation();
|
||||
if (!pending) return;
|
||||
requests.push(pending);
|
||||
store.answerHostConfirmation(
|
||||
pending.id,
|
||||
typeof answer === "function" ? answer(pending, requests.length - 1) : answer,
|
||||
);
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest {
|
||||
const pending = store.getPendingHostConfirmation();
|
||||
if (!pending) throw new Error("Expected a pending host confirmation");
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createPairingStore(
|
||||
input: {
|
||||
hostname?: string;
|
||||
password?: string;
|
||||
connectAttempts?: string[];
|
||||
serverIdForHost?: (host: HostProfile) => string;
|
||||
storage?: HostRuntimeStorage;
|
||||
} = {},
|
||||
): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
storage: input.storage ?? createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host, connection }) => {
|
||||
input.connectAttempts?.push(connection.id);
|
||||
if (input.password && host.password !== input.password) {
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
}
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: input.serverIdForHost?.(host) ?? host.serverId,
|
||||
hostname: input.hostname ?? "paired",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_pairing",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
|
||||
const values = new Map(Object.entries(entries));
|
||||
return {
|
||||
@@ -559,6 +642,21 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean):
|
||||
});
|
||||
}
|
||||
|
||||
function onceHostSnapshotMatches(
|
||||
store: HostRuntimeStore,
|
||||
serverId: string,
|
||||
predicate: (snapshot: ReturnType<HostRuntimeStore["getSnapshot"]>) => boolean,
|
||||
): Promise<void> {
|
||||
if (predicate(store.getSnapshot(serverId))) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = store.subscribe(serverId, () => {
|
||||
if (!predicate(store.getSnapshot(serverId))) return;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class BrowserClientLifecycle {
|
||||
public active: Array<{ serverId: string; connectionId: string }> = [];
|
||||
|
||||
@@ -3350,7 +3448,7 @@ describe("HostRuntimeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("upsertDirectConnection stores SSL and password settings", async () => {
|
||||
it("upsertDirectConnection stores SSL on the connection and password on the host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
@@ -3378,9 +3476,9 @@ describe("HostRuntimeStore", () => {
|
||||
type: "directTcp",
|
||||
endpoint: "example.paseo.test:7443",
|
||||
useTls: true,
|
||||
password: "shared-secret",
|
||||
},
|
||||
]);
|
||||
expect(host?.password).toBe("shared-secret");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
@@ -3465,19 +3563,10 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("mbp");
|
||||
@@ -3486,27 +3575,14 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("stores relay TLS from a pairing offer", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore();
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertConnectionFromOffer(
|
||||
makeOffer({
|
||||
relay: {
|
||||
endpoint: "relay.example.com:443",
|
||||
useTls: true,
|
||||
},
|
||||
}),
|
||||
"tls relay",
|
||||
);
|
||||
await store
|
||||
.beginLinkPairing()
|
||||
.submit(
|
||||
encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })),
|
||||
);
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
@@ -3523,17 +3599,8 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore();
|
||||
answerHostConfirmations(store, true);
|
||||
const oldPairingUrl = encodeOfferUrl({
|
||||
v: 2,
|
||||
serverId: "srv_offer",
|
||||
@@ -3541,7 +3608,7 @@ describe("HostRuntimeStore", () => {
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
|
||||
await store.importConnectionLink(oldPairingUrl, "openProject");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
@@ -3557,28 +3624,267 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
it("probes a pairing link immediately and saves only after admission", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
answerHostConfirmations(store, true);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const pairing = store.beginLinkPairing();
|
||||
|
||||
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
const result = await pairing.submit(offerUrl, "correct-password");
|
||||
expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" });
|
||||
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => {
|
||||
const connectAttempts: string[] = [];
|
||||
const store = createPairingStore({ connectAttempts });
|
||||
const requests = answerHostConfirmations(store, false);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true";
|
||||
|
||||
// `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals.
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
expect(requests).toEqual([
|
||||
...Array.from({ length: 3 }, () => ({
|
||||
id: expect.any(Number),
|
||||
serverId: "srv_offer",
|
||||
keyFingerprint: "pkte stof fer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
kind: "newHost",
|
||||
})),
|
||||
{
|
||||
id: expect.any(Number),
|
||||
serverId: "srv_relay",
|
||||
keyFingerprint: "AAAA",
|
||||
relayEndpoint: "relay.example:443",
|
||||
kind: "newHost",
|
||||
},
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
});
|
||||
]);
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
expect(connectAttempts).toEqual([]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
await store.upsertRelayConnection({
|
||||
it("connects a saved host from a matching pairing link without asking", async () => {
|
||||
const store = createPairingStore();
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
await store.beginLinkPairing().submit(offerUrl);
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_offer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
label: "Custom name",
|
||||
});
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(store.getHosts()).toHaveLength(1);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => {
|
||||
const store = createPairingStore();
|
||||
const requests = answerHostConfirmations(store, (_request, index) => index === 0);
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
const savedHosts = store.getHosts();
|
||||
|
||||
const changedLinks = [
|
||||
makeOffer({ daemonPublicKeyB64: "pk_other_key" }),
|
||||
makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }),
|
||||
makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }),
|
||||
].map(encodeOfferUrl);
|
||||
for (const link of changedLinks) {
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
expect(requests.map((request) => request.kind)).toEqual([
|
||||
"newHost",
|
||||
...Array.from({ length: 6 }, () => "changedConnection"),
|
||||
]);
|
||||
expect(store.getHosts()).toEqual(savedHosts);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks once per pairing flow and link when the host asks for a password", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
const requests = answerHostConfirmations(store, (_request, index) => index < 2);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const pairing = store.beginLinkPairing();
|
||||
|
||||
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
|
||||
await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow();
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
// A new pairing flow for the same link asks again.
|
||||
await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow();
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({
|
||||
status: "connected",
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_offer",
|
||||
password: "correct-password",
|
||||
});
|
||||
|
||||
// A different link in the same flow asks again.
|
||||
const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
|
||||
await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(requests).toHaveLength(3);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("continues a scanned link's password retry without asking again", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||
|
||||
const outcome = await store.importConnectionLink(link, "hostRoot");
|
||||
expect(outcome).toMatchObject({ status: "password_required", link });
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
if (outcome.status !== "password_required") throw new Error("expected password_required");
|
||||
|
||||
await outcome.pairing.submit(outcome.link, "correct-password");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_pair",
|
||||
password: "correct-password",
|
||||
});
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => {
|
||||
const store = createPairingStore();
|
||||
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
|
||||
);
|
||||
|
||||
const second = store.importConnectionLink(
|
||||
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
|
||||
"openProject",
|
||||
);
|
||||
await expect(first).resolves.toEqual({ status: "cancelled" });
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }),
|
||||
);
|
||||
|
||||
store.answerHostConfirmation(requirePendingConfirmation(store).id, true);
|
||||
await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" });
|
||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("ignores an answer for a host confirmation that a newer one replaced", async () => {
|
||||
const store = createPairingStore();
|
||||
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
|
||||
);
|
||||
const firstRequest = requirePendingConfirmation(store);
|
||||
const second = store.importConnectionLink(
|
||||
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
|
||||
"openProject",
|
||||
);
|
||||
await expect(first).resolves.toEqual({ status: "cancelled" });
|
||||
|
||||
// Connect tapped on the first sheet as the second link arrives.
|
||||
store.answerHostConfirmation(firstRequest.id, true);
|
||||
|
||||
const secondRequest = requirePendingConfirmation(store);
|
||||
expect(secondRequest.serverId).toBe("srv_second");
|
||||
store.answerHostConfirmation(secondRequest.id, false);
|
||||
await expect(second).resolves.toEqual({ status: "cancelled" });
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for saved hosts to load before deciding whether a pairing link asks", async () => {
|
||||
const savedHost = makeHost({
|
||||
serverId: "srv_offer",
|
||||
connections: [
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
],
|
||||
});
|
||||
const store = createPairingStore({
|
||||
storage: createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([savedHost]),
|
||||
"@paseo:e2e": "1",
|
||||
}),
|
||||
});
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
|
||||
await expect(
|
||||
store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"),
|
||||
).resolves.toEqual({ status: "connected", serverId: "srv_offer" });
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("adds hosts by address and the default localhost without asking", async () => {
|
||||
const store = createPairingStore({
|
||||
serverIdForHost: (host) =>
|
||||
host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "",
|
||||
});
|
||||
const requests = answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" });
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual([
|
||||
"srv_localhost:6767",
|
||||
"srv_10.0.0.5:6767",
|
||||
]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
await store.renameHost("srv_offer", "Custom name");
|
||||
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("Custom name");
|
||||
@@ -3625,6 +3931,294 @@ describe("readInitialDaemonConnectionHint", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
it("starts saved remote-host probes while desktop daemon startup is still pending", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_remote_saved",
|
||||
connections: [
|
||||
{ id: "direct:remote.example:6799", type: "directTcp", endpoint: "remote.example:6799" },
|
||||
],
|
||||
});
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
let remoteProbeStarted = false;
|
||||
let finishDesktopStart: (() => void) | undefined;
|
||||
const desktopStartPending = new Promise<void>((resolve) => {
|
||||
finishDesktopStart = resolve;
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
remoteProbeStarted = true;
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_remote_saved",
|
||||
},
|
||||
});
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
shouldStartDaemon: true,
|
||||
daemonStartService: {
|
||||
startIfEnabled: async () => {
|
||||
await desktopStartPending;
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(remoteProbeStarted).toBe(true), { timeout: 400 });
|
||||
expect(store.isHostRegistryLoaded()).toBe(true);
|
||||
} finally {
|
||||
finishDesktopStart?.();
|
||||
await store.boot();
|
||||
store.syncHosts([]);
|
||||
}
|
||||
});
|
||||
it("resolves a local credential for a reopened saved desktop host connection", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_desktop_reopened",
|
||||
connections: [{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" }],
|
||||
});
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
const credentialRequests: string[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: probeHost, connection }) => {
|
||||
expect(probeHost.password).toBeUndefined();
|
||||
const credential = await readDesktopManagedLocalCredential(connection, async (listen) => {
|
||||
credentialRequests.push(listen);
|
||||
return listen === "localhost:6799" ? "local-token" : null;
|
||||
});
|
||||
expect(credential).toBe("local-token");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_desktop_reopened",
|
||||
},
|
||||
});
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
shouldStartDaemon: true,
|
||||
daemonStartService: {
|
||||
startIfEnabled: async () => ({ ok: true }),
|
||||
},
|
||||
});
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
expect(credentialRequests).toContain("localhost:6799");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("requires a new password and stops reconnecting after a previously connected client is rejected", async () => {
|
||||
const client = makeConnectedProbeClient(5);
|
||||
const host = makeHost({
|
||||
serverId: "srv_changed_password",
|
||||
password: "old-password",
|
||||
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => client as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
}),
|
||||
getClientId: async () => "cid_changed_password",
|
||||
},
|
||||
});
|
||||
store.syncHosts([host], {
|
||||
initialConnectionByServerId: new Map([
|
||||
[
|
||||
host.serverId,
|
||||
{
|
||||
connectionId: host.connections[0]!.id,
|
||||
existingClient: client as unknown as DaemonClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
client.authFailureReason = "incorrect_password";
|
||||
client.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
host.serverId,
|
||||
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
|
||||
);
|
||||
expect(store.getSnapshot(host.serverId)?.lastError).toBe("Incorrect password");
|
||||
expect(client.reconnectEnabledChanges.at(-1)).toBe(false);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("tries another connection after the active connection rejects its password", async () => {
|
||||
const relayClient = makeConnectedProbeClient(5);
|
||||
const directClient = makeConnectedProbeClient(5);
|
||||
const host = makeHost({
|
||||
serverId: "srv_rejected_relay_with_local_direct",
|
||||
password: "stale-password",
|
||||
preferredConnectionId: "relay:remote:443",
|
||||
connections: [
|
||||
{
|
||||
id: "relay:remote:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "remote:443",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" },
|
||||
],
|
||||
});
|
||||
const probeAttempts: string[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ connection }) => {
|
||||
probeAttempts.push(connection.id);
|
||||
if (connection.type === "relay")
|
||||
throw new DaemonAuthenticationError("incorrect_password");
|
||||
return {
|
||||
client: directClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_rejected_relay_with_local_direct",
|
||||
},
|
||||
});
|
||||
store.syncHosts([host], {
|
||||
initialConnectionByServerId: new Map([
|
||||
[
|
||||
host.serverId,
|
||||
{
|
||||
connectionId: "relay:remote:443",
|
||||
existingClient: relayClient as unknown as DaemonClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
relayClient.authFailureReason = "incorrect_password";
|
||||
relayClient.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
host.serverId,
|
||||
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
|
||||
);
|
||||
await store.runProbeCycleNow(host.serverId);
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
host.serverId,
|
||||
(snapshot) =>
|
||||
snapshot?.connectionStatus === "online" &&
|
||||
snapshot.activeConnectionId === "direct:localhost:6799",
|
||||
);
|
||||
expect(probeAttempts).toContain("direct:localhost:6799");
|
||||
expect(probeAttempts).not.toContain("relay:remote:443");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps five rejected hosts red with independent password errors", async () => {
|
||||
let probeAttempts = 0;
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
probeAttempts += 1;
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
},
|
||||
getClientId: async () => "cid_five_rejected_hosts",
|
||||
},
|
||||
});
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
const serverId = `srv_rejected_${index}`;
|
||||
await store.upsertDirectConnection({ serverId, endpoint: `host-${index}:6767` });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
serverId,
|
||||
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||
);
|
||||
}
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
const snapshot = store.getSnapshot(`srv_rejected_${index}`);
|
||||
expect(snapshot?.connectionStatus).toBe("error");
|
||||
expect(snapshot?.lastError).toBe("Password required");
|
||||
}
|
||||
const attemptsBeforeRetry = probeAttempts;
|
||||
await store.runProbeCycleNow("srv_rejected_1");
|
||||
expect(probeAttempts).toBe(attemptsBeforeRetry);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
},
|
||||
getClientId: async () => "cid_deep_link",
|
||||
},
|
||||
});
|
||||
answerHostConfirmations(store, true);
|
||||
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_deep",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(1);
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
"srv_deep",
|
||||
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||
);
|
||||
expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("saves and reconnects a rejected saved host after changing its password", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
if (host.password !== "correct-password")
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_password_setting",
|
||||
},
|
||||
});
|
||||
await store.upsertDirectConnection({ serverId: "srv_test", endpoint: "lan:6767" });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
"srv_test",
|
||||
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||
);
|
||||
expect(store.getSnapshot("srv_test")?.connectionStatus).toBe("error");
|
||||
await store.setHostPassword("srv_test", "correct-password");
|
||||
await waitForHostOnline(store, "srv_test");
|
||||
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||
expect(store.getSnapshot("srv_test")?.authFailureReason).toBeNull();
|
||||
await store.setHostPassword("srv_test", "");
|
||||
expect(store.getHosts()[0]?.password).toBeUndefined();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
|
||||
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
|
||||
@@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
DaemonClient,
|
||||
DaemonAuthenticationError,
|
||||
type DaemonClientConfig,
|
||||
type ConnectionState,
|
||||
type FetchAgentsOptions,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
normalizeStoredHostProfile,
|
||||
upsertHostConnectionInProfiles,
|
||||
registryHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
StoredHostRegistrySchema,
|
||||
type HostConnection,
|
||||
type HostProfile,
|
||||
@@ -23,13 +25,15 @@ import {
|
||||
buildRelayWebSocketUrl,
|
||||
decodeOfferFragmentPayload,
|
||||
normalizeHostPort,
|
||||
parseRelayConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
createDesktopDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import {
|
||||
@@ -82,8 +87,30 @@ import { revokePushNotifications } from "@/push-notifications";
|
||||
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
|
||||
/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */
|
||||
export interface LinkPairing {
|
||||
/** Probes the linked host and saves it. Connection and password errors throw. */
|
||||
submit(link: string, password?: string): Promise<LinkPairingResult>;
|
||||
}
|
||||
|
||||
export type LinkPairingResult =
|
||||
| { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null }
|
||||
| { status: "cancelled" };
|
||||
|
||||
/** A pairing link the user confirmed whose host then asked for a password. */
|
||||
export interface PasswordRequiredPairing {
|
||||
link: string;
|
||||
pairing: LinkPairing;
|
||||
}
|
||||
|
||||
export type ConnectionLinkImport =
|
||||
| { status: "connected"; serverId: string }
|
||||
| ({ status: "password_required" } & PasswordRequiredPairing)
|
||||
| { status: "cancelled" };
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
@@ -106,6 +133,7 @@ export interface HostRuntimeSnapshot {
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
client: DaemonClient | null;
|
||||
lastError: string | null;
|
||||
authFailureReason?: "password_required" | "incorrect_password" | null;
|
||||
lastOnlineAt: string | null;
|
||||
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
||||
agentDirectoryError: string | null;
|
||||
@@ -517,6 +545,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
providerSnapshots: "wire",
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
@@ -550,7 +580,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
@@ -570,6 +599,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
connectToDaemon: ({ host, connection, timeoutMs }) =>
|
||||
connectToDaemon(connection, {
|
||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
@@ -617,6 +648,7 @@ export class HostRuntimeController {
|
||||
private switchRequestVersion = 0;
|
||||
private probeRequestVersion = 0;
|
||||
private probeCycleInFlight: Promise<void> | null = null;
|
||||
private readonly authRejectedConnectionIds = new Set<string>();
|
||||
|
||||
constructor(input: {
|
||||
host: HostProfile;
|
||||
@@ -705,6 +737,12 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
async updateHost(host: HostProfile): Promise<void> {
|
||||
const passwordChanged = this.host.password !== host.password;
|
||||
if (passwordChanged) {
|
||||
this.probeRequestVersion += 1;
|
||||
this.authRejectedConnectionIds.clear();
|
||||
this.updateSnapshot({ authFailureReason: null });
|
||||
}
|
||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
|
||||
this.host = host;
|
||||
@@ -714,11 +752,13 @@ export class HostRuntimeController {
|
||||
activeConnectionId &&
|
||||
previousActiveConnection &&
|
||||
nextActiveConnection &&
|
||||
!equal(previousActiveConnection, nextActiveConnection)
|
||||
(!equal(previousActiveConnection, nextActiveConnection) || passwordChanged)
|
||||
) {
|
||||
this.connectionLastProbedAt.delete(activeConnectionId);
|
||||
await this.switchToConnection({ connectionId: activeConnectionId });
|
||||
}
|
||||
if (passwordChanged) this.connectionLastProbedAt.clear();
|
||||
if (passwordChanged) await this.probeCycleInFlight;
|
||||
await this.runProbeCycleNow();
|
||||
}
|
||||
|
||||
@@ -808,6 +848,7 @@ export class HostRuntimeController {
|
||||
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
|
||||
|
||||
const connectionsToProbe = this.host.connections.filter((connection) => {
|
||||
if (this.authRejectedConnectionIds.has(connection.id)) return false;
|
||||
const lastProbed = this.connectionLastProbedAt.get(connection.id);
|
||||
if (lastProbed == null) {
|
||||
return true;
|
||||
@@ -843,6 +884,7 @@ export class HostRuntimeController {
|
||||
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
||||
|
||||
let remaining = connectionsToProbe.length;
|
||||
let probeAuthFailure: "password_required" | "incorrect_password" | null = null;
|
||||
let activationLock: Promise<void> | null = null;
|
||||
|
||||
const publishProbeState = (): void => {
|
||||
@@ -893,11 +935,18 @@ export class HostRuntimeController {
|
||||
connectionId: nextConnectionId,
|
||||
expectedProbeVersion: requestVersion,
|
||||
});
|
||||
} else if (probeAuthFailure) {
|
||||
const message = new DaemonAuthenticationError(probeAuthFailure).message;
|
||||
this.applyConnectionEvent({ type: "connect_failed", message });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
authFailureReason: probeAuthFailure,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProbe?.status === "unavailable") {
|
||||
if (this.isConnectionUnavailable(currentActiveConnectionId, activeProbe)) {
|
||||
const nextConnectionId = selectBestConnection({
|
||||
candidates: buildConnectionCandidates(this.host),
|
||||
probeByConnectionId,
|
||||
@@ -1030,8 +1079,13 @@ export class HostRuntimeController {
|
||||
latencyMs: rttMs,
|
||||
});
|
||||
publishProbeState();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (this.isCurrentProbeRequest(requestVersion)) {
|
||||
const authFailure = getConnectionAuthFailureReason(error);
|
||||
if (authFailure) {
|
||||
this.authRejectedConnectionIds.add(connection.id);
|
||||
probeAuthFailure = authFailure;
|
||||
}
|
||||
probeByConnectionId.set(connection.id, {
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
@@ -1049,6 +1103,13 @@ export class HostRuntimeController {
|
||||
});
|
||||
}
|
||||
|
||||
private isConnectionUnavailable(
|
||||
connectionId: string,
|
||||
probe: ConnectionProbeState | null | undefined,
|
||||
): boolean {
|
||||
return probe?.status === "unavailable" || this.authRejectedConnectionIds.has(connectionId);
|
||||
}
|
||||
|
||||
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
||||
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
||||
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
||||
@@ -1257,6 +1318,8 @@ export class HostRuntimeController {
|
||||
|
||||
const failConnection = async (error: unknown) => {
|
||||
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
||||
const authFailure = getConnectionAuthFailureReason(error);
|
||||
if (authFailure) this.authRejectedConnectionIds.add(connection.id);
|
||||
this.unsubscribeClientStatus?.();
|
||||
this.unsubscribeClientStatus = null;
|
||||
this.unsubscribeClientHandlers?.();
|
||||
@@ -1266,12 +1329,29 @@ export class HostRuntimeController {
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
client: null,
|
||||
authFailureReason: authFailure,
|
||||
});
|
||||
if (authFailure) {
|
||||
const probes = new Map(this.snapshot.probeByConnectionId);
|
||||
probes.set(connection.id, { status: "unavailable", latencyMs: null });
|
||||
this.updateSnapshot({ probeByConnectionId: probes });
|
||||
for (const candidate of this.host.connections) {
|
||||
if (!this.authRejectedConnectionIds.has(candidate.id)) {
|
||||
this.connectionLastProbedAt.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
/* Preserve the compatibility/connection error. */
|
||||
}
|
||||
if (
|
||||
authFailure &&
|
||||
this.host.connections.some((candidate) => !this.authRejectedConnectionIds.has(candidate.id))
|
||||
) {
|
||||
void this.runProbeCycleNow();
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!existingClient) await client.connect();
|
||||
@@ -1280,11 +1360,16 @@ export class HostRuntimeController {
|
||||
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
|
||||
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
|
||||
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
||||
if (client.authFailureReason) {
|
||||
void failConnection(new DaemonAuthenticationError(client.authFailureReason));
|
||||
return;
|
||||
}
|
||||
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
...this.buildAgentDirectoryStatusPatch(),
|
||||
client,
|
||||
authFailureReason: null,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1345,6 +1430,26 @@ function readConfiguredLocalDaemonOverride(): string | null {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean {
|
||||
return (
|
||||
left.serverId === right.serverId &&
|
||||
equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right))
|
||||
);
|
||||
}
|
||||
|
||||
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
|
||||
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
|
||||
return parseRelayConnectionUri(input);
|
||||
}
|
||||
const marker = "#offer=";
|
||||
const idx = input.indexOf(marker);
|
||||
if (idx === -1) throw new Error("Missing #offer= fragment");
|
||||
const encoded = input.slice(idx + marker.length).trim();
|
||||
if (!encoded) throw new Error("Offer payload is empty");
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return { offer: ConnectionOfferSchema.parse(payload) };
|
||||
}
|
||||
|
||||
export function hasConfiguredLocalDaemonOverride(): boolean {
|
||||
return readConfiguredLocalDaemonOverride() !== null;
|
||||
}
|
||||
@@ -1393,6 +1498,8 @@ export class HostRuntimeStore {
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private registryLoad: Promise<void> | null = null;
|
||||
private readonly hostConfirmations = new HostConfirmations();
|
||||
private storage: HostRuntimeStorage;
|
||||
private replicaCache: ReplicaCache;
|
||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||
@@ -1443,8 +1550,7 @@ export class HostRuntimeStore {
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
const override = readConfiguredLocalDaemonOverride();
|
||||
await this.loadFromStorage();
|
||||
this.markHostRegistryLoaded();
|
||||
await this.loadRegistry();
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
@@ -1477,6 +1583,13 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private loadRegistry(): Promise<void> {
|
||||
if (!this.registryLoad) {
|
||||
this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded());
|
||||
}
|
||||
return this.registryLoad;
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
let shouldPersistHosts = false;
|
||||
let profiles: HostProfile[] = [];
|
||||
@@ -1495,6 +1608,14 @@ export class HostRuntimeStore {
|
||||
normalizedProfiles.length = 0;
|
||||
break;
|
||||
}
|
||||
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 with stored-password migration.
|
||||
if (
|
||||
entry.connections.some(
|
||||
(connection) => connection.type === "directTcp" && connection.password,
|
||||
)
|
||||
) {
|
||||
shouldPersistHosts = true;
|
||||
}
|
||||
normalizedProfiles.push(profile);
|
||||
}
|
||||
profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
||||
@@ -1717,12 +1838,12 @@ export class HostRuntimeStore {
|
||||
return this.upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
password,
|
||||
connection: {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
},
|
||||
existingClient: input.existingClient,
|
||||
});
|
||||
@@ -1730,6 +1851,7 @@ export class HostRuntimeStore {
|
||||
|
||||
async probeAndUpsertConnection(input: {
|
||||
connection: HostConnection;
|
||||
password?: string;
|
||||
label?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
@@ -1738,6 +1860,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
const probeHost: HostProfile = {
|
||||
serverId: "",
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
label: input.label ?? input.connection.id,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
@@ -1754,6 +1877,7 @@ export class HostRuntimeStore {
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.label ?? hostname ?? undefined,
|
||||
password: input.password,
|
||||
connection: input.connection,
|
||||
existingClient: client,
|
||||
});
|
||||
@@ -1770,12 +1894,12 @@ export class HostRuntimeStore {
|
||||
const password = input.password?.trim();
|
||||
return this.probeAndUpsertConnection({
|
||||
label: input.label,
|
||||
password,
|
||||
connection: {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1792,12 +1916,13 @@ export class HostRuntimeStore {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertRelayConnection(input: {
|
||||
private async upsertRelayConnection(input: {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
useTls?: boolean;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
}): Promise<HostProfile> {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||
const useTls = input.useTls ?? false;
|
||||
@@ -1806,7 +1931,7 @@ export class HostRuntimeStore {
|
||||
throw new Error("daemonPublicKeyB64 is required");
|
||||
}
|
||||
const explicitUseTls = input.useTls !== undefined;
|
||||
return this.upsertHostConnection({
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: {
|
||||
@@ -1817,36 +1942,110 @@ export class HostRuntimeStore {
|
||||
daemonPublicKeyB64,
|
||||
},
|
||||
});
|
||||
if (input.password) {
|
||||
await this.setHostPassword(profile.serverId, input.password);
|
||||
return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||
await this.loadRegistry();
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||
}
|
||||
|
||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||
return this.hostConfirmations.getPending();
|
||||
}
|
||||
|
||||
subscribeHostConfirmation(listener: () => void): () => void {
|
||||
return this.hostConfirmations.subscribe(listener);
|
||||
}
|
||||
|
||||
answerHostConfirmation(requestId: number, approved: boolean): void {
|
||||
this.hostConfirmations.answer(requestId, approved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one pairing flow, such as one open pairing modal. Once the user
|
||||
* confirms a link, submitting the same link again in this flow (the
|
||||
* password retry) does not ask again. A new flow asks again.
|
||||
*/
|
||||
beginLinkPairing(): LinkPairing {
|
||||
let approvedOffer: ConnectionOffer | null = null;
|
||||
return {
|
||||
submit: async (link, password) => {
|
||||
const { offer, password: linkPassword } = parseOfferConnectionUrl(link);
|
||||
if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) {
|
||||
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
|
||||
approvedOffer = offer;
|
||||
}
|
||||
const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword);
|
||||
return { status: "connected", ...result };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async importConnectionLink(
|
||||
url: string,
|
||||
target: PairingNavigationTarget,
|
||||
): Promise<ConnectionLinkImport> {
|
||||
if (target === "openProject") {
|
||||
const { offer, password } = parseOfferConnectionUrl(url);
|
||||
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
|
||||
const profile = await this.upsertConnectionFromOffer(offer, undefined, password);
|
||||
return { status: "connected", serverId: profile.serverId };
|
||||
}
|
||||
const pairing = this.beginLinkPairing();
|
||||
try {
|
||||
const result = await pairing.submit(url);
|
||||
if (result.status === "cancelled") return result;
|
||||
return { status: "connected", serverId: result.serverId };
|
||||
} catch (error) {
|
||||
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||
return { status: "password_required", link: url, pairing };
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertConnectionFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls: connection.useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
label,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
): Promise<HostProfile> {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error("Missing #offer= fragment");
|
||||
}
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(payload);
|
||||
return this.upsertConnectionFromOffer(offer, label);
|
||||
private async probeAndUpsertOffer(
|
||||
offer: ConnectionOffer,
|
||||
password: string | undefined,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const probeHost: HostProfile = {
|
||||
serverId: offer.serverId,
|
||||
...(password ? { password } : {}),
|
||||
label: offer.serverId,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
async upsertConnectionFromListen(input: {
|
||||
@@ -1886,6 +2085,13 @@ export class HostRuntimeStore {
|
||||
await this.updateHost(serverId, (host) => ({ ...host, label }));
|
||||
}
|
||||
|
||||
async setHostPassword(serverId: string, password: string): Promise<void> {
|
||||
await this.updateHost(serverId, (host) => {
|
||||
const { password: _previous, ...rest } = host;
|
||||
return password ? { ...rest, password } : rest;
|
||||
});
|
||||
}
|
||||
|
||||
async setHostColor(serverId: string, color: HostColor): Promise<void> {
|
||||
await this.updateHostAppearance(serverId, (host) => ({
|
||||
...host,
|
||||
@@ -1963,6 +2169,7 @@ export class HostRuntimeStore {
|
||||
private async upsertHostConnection(input: {
|
||||
serverId: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
connection: HostConnection;
|
||||
existingClient?: DaemonClient;
|
||||
}): Promise<HostProfile> {
|
||||
@@ -1972,6 +2179,7 @@ export class HostRuntimeStore {
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: input.connection,
|
||||
password: input.password,
|
||||
now,
|
||||
});
|
||||
this.setHostsAndSync(next, {
|
||||
@@ -2579,6 +2787,29 @@ export function useHostRegistryLoaded(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostConfirmation(): {
|
||||
pending: HostConfirmationRequest | null;
|
||||
answer: (approved: boolean) => void;
|
||||
} {
|
||||
const store = getHostRuntimeStore();
|
||||
const pending = useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribeHostConfirmation(onStoreChange),
|
||||
() => store.getPendingHostConfirmation(),
|
||||
() => store.getPendingHostConfirmation(),
|
||||
);
|
||||
// The answer is bound to the request this render shows, so a tap never
|
||||
// answers a request that replaced it.
|
||||
return useMemo(
|
||||
() => ({
|
||||
pending,
|
||||
answer: (approved: boolean) => {
|
||||
if (pending) store.answerHostConfirmation(pending.id, approved);
|
||||
},
|
||||
}),
|
||||
[pending, store],
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostMutations {
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string;
|
||||
@@ -2599,18 +2830,7 @@ export interface HostMutations {
|
||||
daemonPort?: number;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
upsertRelayConnection: (input: {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
useTls?: boolean;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
) => Promise<HostProfile>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
@@ -2625,9 +2845,7 @@ export function useHostMutations(): HostMutations {
|
||||
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
||||
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
|
||||
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
|
||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
||||
beginLinkPairing: () => store.beginLinkPairing(),
|
||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
||||
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user