Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 919c737c19 chore(release): cut 0.10.2 2026-09-30 01:13:57 +02:00
Mohamed Boudra c5b4b7bae3 docs(changelog): add 0.10.2 release notes 2026-09-30 01:09:50 +02:00
paseo-bot[bot] 81fc6134a9 fix(opencode): fill the context meter during v2 turns (#5710)
OpenCode v2 sessions never reported context used or the model's context
limit, so the composer meter was an empty ring during a turn and vanished
when it ended. Each finished step now reports its context against the
selected model's limit.

Closes #5701

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
(cherry picked from commit d3c902e9fa)
2026-09-30 00:33:59 +02:00
paseo-bot[bot] 9744da6e4f fix(opencode): show v2 patch edits as edit diffs (#5696)
OpenCode 2 reports GPT models' apply_patch tool calls under the name
patch. The OpenCode tool-call parser only recognized apply_patch, so
these calls fell through to an unknown tool card with the raw patch
text. Map patch payloads the same way as apply_patch.

Closes #5643

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 5764863f63)
2026-09-30 00:33:59 +02:00
paseo-bot[bot] 86df0587ba fix(opencode): show the question text and typed answer in v2 question cards (#5679)
OpenCode v2 sends each question as a form field whose title is the short
header, whose description is the question, and whose custom flag allows a
typed answer. The mapper used the field key as the header and the title as
the question, and dropped custom, so the card showed only the header and
had no answer field.

Closes #5667

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 2838922ade)
2026-09-30 00:33:59 +02:00
Mohamed Boudra e26eb483e2 fix(opencode): keep long v2 turns alive until execution completes (#5674)
The long-lived session wait hit Node’s headers timeout while OpenCode kept working. Reconcile execution events against active state and the durable log so transport observation failures do not fail healthy turns, including across event gaps and reconnects.

(cherry picked from commit be2fb1cab9)
2026-09-30 00:33:59 +02:00
paseo-bot[bot] ee89f4917f fix(opencode): keep v2 edit text in completed edit details (#5609)
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.

Closes #5607

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit d91171954b)
2026-09-30 00:33:59 +02:00
Mohamed Boudra c5236c00d8 chore(release): cut 0.10.1 2026-09-29 00:06:23 +02:00
Mohamed Boudra d1443064db docs(changelog): add 0.10.1 release notes 2026-09-29 00:03:24 +02:00
Mohamed Boudra 9f387873b0 fix(opencode): clear unsupported thinking when changing models (#5587)
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.

(cherry picked from commit 940dbfd242)
2026-09-28 23:37:09 +02:00
Qi 12cd5345d2 feat(claude): add Sonnet 5.5 with its model capabilities (#5583)
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.

Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.

The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.

(cherry picked from commit 4340607093)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 2c6d4e5283 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit f4ba16a0b3)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 006bae2897 Archive a custom Codex provider's session when its agent is archived (#5572)
A provider that extends Codex runs through the registry's client wrapper,
which forwarded session listing and import but not native archive or
unarchive. Archiving such an agent left its Codex thread unarchived, so
the session stayed in Import session and could be imported again. The
wrapper now forwards both, the same as stock Codex.

Closes #5454

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 9d010211a2)
2026-09-28 23:36:50 +02:00
Mohamed Boudra 437aef1a7b Show Windows file links relative to their workspace (#1987)
* fix(app): show Windows file links relative to workspace

Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.

* fix(app): preserve workspace-root link tooltips

Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.

* fix(app): share workspace path rules for file link tooltips

(cherry picked from commit cf4509631b)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] b44e7b0b90 Open a subagent in the split pane it was opened from (#5451)
* fix(app): open a subagent in the pane it was opened from

Opening a subagent from an agent's composer placed the new tab in the
focused pane. Clicking the subagent row does not focus its pane, so in a
split view the subagent landed in whichever pane last had focus.

Place an implicit main-location open that has a parent tab in that tab's
pane.

Refs #5433

* test(app): cover subagent split-pane placement in browser

* test(app): use browser repro for subagent pane placement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit 7bb7d4ed08)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 09f87df1b2 Report a background send_agent_prompt's started turn as running (#5386)
* Report a background send_agent_prompt's started turn as running

The background branch read the target's status right after dispatch,
before the provider acknowledged the turn, so an idle target read as
idle. Wait for the run to start first, as create_agent and the app's
send path already do.

* Check the caller still gets the finish notification after a background send

* Acknowledge the held turn start explicitly in the send_agent_prompt test

* Cover immediate completion after background turn start

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit 8de52a3c99)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] b013e0d428 fix(codex): read custom prompts from the provider's own CODEX_HOME (#5450)
A provider that extends codex and sets CODEX_HOME in its env runs Codex
against that home, but the daemon listed, expanded, and fell back to
skills from its own CODEX_HOME or ~/.codex. The / menu offered another
home's prompts and never the provider's own, and invoking one of the
provider's prompts sent the raw slash text to Codex.

Resolve the Codex home from the same environment the app-server is
launched with, and give it to the session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit dffde6ae8b)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] c938e1cf44 Show the Cursor icon for terminal profiles that run cursor-agent (#5379)
* Show the Cursor icon for terminal profiles that run cursor-agent

The Cursor CLI binary is cursor-agent, so a profile created in settings
with that command matched no known icon and fell back to the generic
terminal icon. Map cursor-agent to the cursor icon while keeping
command matching exact.

Closes #4702

* Verify Cursor terminal profile icons in real browser

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit a43c8d888f)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 0c18144967 Keep a custom Codex provider and Paseo tools after rewinding
Rewound Codex forks receive the same provider and runtime MCP config as thread start and resume. Covers paginated and legacy rewind paths.\n\nCloses #4542\nCloses #3205\n\nCo-authored-by: zbaibg <zbai29@wisc.edu>

(cherry picked from commit 827178df90)
2026-09-28 23:36:50 +02:00
Mohamed Boudra c481ecf3e1 chore(release): cut 0.10.0 2026-09-28 14:27:01 +02:00
Mohamed Boudra dfc9add774 docs: collapse 0.10.0 changelog for stable 2026-09-28 14:05:14 +02:00
Grisha SzepandMohamed Boudra da48803a43 fix(opencode): restore complete session environments on connection (#5526)
* fix(opencode): restore complete session environments on connection

* fix(opencode): use the shared provider environment policy

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 12:55:21 +01:00
Mohamed Boudra 7f5d32cdd5 website: add Paseo vs Orca alternative page (#5537) 2026-09-28 12:05:17 +01:00
Mohamed Boudra dec2d861ec Add a Philosophy section to the homepage (#5538)
* feat(website): add Philosophy section to homepage

* feat(website): reshape Philosophy section to the homepage's FeatureSection pattern
2026-09-28 12:04:15 +01:00
paseo-ai[bot] 30178c4f58 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-27 12:01:37 +00:00
Mohamed Boudra 52d345db7f chore(release): cut 0.10.0-beta.1 2026-09-27 13:51:11 +02:00
Mohamed Boudra a50a476009 docs: add 0.10.0-beta.1 changelog 2026-09-27 13:48:06 +02:00
Mohamed Boudra 6ca001c3e4 chore: normalize package-lock ordering for release 2026-09-27 13:48:04 +02:00
Mohamed Boudra 78a0f093eb fix(opencode): recover v2 chats after helper exit (#5488)
* fix(opencode): reconnect v2 sessions after helper exits

A helper exit aborted the session lifetime, so every later prompt failed even though OpenCode can serve the same native session from another helper. Keep the session open, fail the active turn, and acquire and configure the current helper on the next OpenCode operation.

* fix(opencode): forget exited v2 helper generation

A dead shared helper could remain selected as the current generation. Clear that selection at exit so the first lazy reconnect acquires a live helper.
2026-09-27 13:25:49 +02:00
Mohamed Boudra d7b7016cc9 feat(settings): polish navigation and controls (#5459)
* feat(settings): polish navigation and controls

* test(settings): update navigation assertions
2026-09-26 20:26:04 +02:00
Mohamed Boudra e1c769c018 Make password-protected daemons work across local and remote connections (#5393)
* feat(auth): authenticate daemon clients through hello

Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release.

Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows.

* fix(auth): preserve Hub bootstrap and local credential expectations

Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports.

Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change.

* fix(pairing): retain passwords across edits to the same target

Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target.

* test(auth): align local credential checks across platforms

Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them.

* feat(app): move host password into a settings row with a modal

The host page rendered an always-visible password form above Name on every
host. Replace it with a Password row under a Security section below
Appearance; tapping it opens a modal with Save and, when a password is saved,
Clear. The connection-error callout keeps its guidance and gains a Set
password button that opens the same modal. Drop the unused
pairing.hostPassword body/reopen/saveError strings.

* chore: remove committed QA screenshots

QA evidence stays out of the repository; the PR body describes it in words.

* feat(app): keep the host password out of the host page

The saved password is connection data, entered only when a host is added. A
Password setting on the host page reads as the daemon's own password, so the
row, modal, and callout button are gone. A rejected host shows the daemon's
reason with guidance to remove the host and add it again with the password.
useHostMutations no longer exposes setHostPassword; relay imports still set it
inside the runtime.

* feat(app): show host connection errors in an error alert

HostConnectionError renders the existing Alert with the connection error as
its title. Auth rejections add the re-add guidance as the description; other
errors show the title only. The Overview and Connections pages share it.

* style(app): soften alert borders to half-opacity accent

* feat(app): size alerts on the button scale

Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to
the button's in control-geometry: one font size for title and description, the
button icon size centered on the first text line, button padding and radius,
and a one-line alert as tall as the button of the same size. Consumers pick
sm among settings rows, cards, panels, and modals, and md when the alert is
the whole page or sheet state.

* style(app): roomier, rounder alerts with a tight title block

Alert sm padding returns to the original 12/16 and every size is at least
borderRadius.xl. Title and description sit a small gap apart with no line
height override. The icon slot carries a zero-width text strut in the alert's
font so the icon centers on the first line for titled, title-only, and
description-only alerts.

* refactor(app): align the alert icon with a centered lead row

Drop the zero-width strut. The icon and the alert's first text (title, else
description) share one row centered on each other; the description and actions
sit below, indented by the icon slot width plus gap so they start at the lead
text's left edge.

* refactor(app): drop the unused Alert icon override
2026-09-26 19:53:43 +02:00
Mohamed Boudra c081e0350d Support popular Pi extensions through per-extension adapters (#5309)
* refactor(server): give Pi extension handling a registry

* feat(server): map Pi task extensions to Paseo task lists

* feat(server): map Pi subagent extensions to the Subagents track

* refactor(server): hydrate Pi child sessions asynchronously through the extension host

Child session JSONL reads blocked the daemon during live events and replay. The extension host now prepares immediate upserts and bounded async timeline hydration; replay awaits it, and closed sessions discard it. Preserve claimed notification text so completion details remain visible in the parent conversation.

* feat(server): present Pi question extensions as one question form

Keep the extension questionnaire in one Paseo permission while Pi walks its RPC dialogs. The host defers an early dialog until the form is answered, then replies to each dialog using the collected choices.

* fix(server): satisfy Pi extension start hook contract

The server build checks the session interface more strictly than the fast typecheck. Return the optional permission explicitly so the adapter satisfies the published extension contract.

* docs: document Pi extension adapters

* chore(server): format Pi subagent captures

These real RPC fixtures were added before the formatter ran over the captured JSON. Normalize whitespace so repository format checks pass without changing the captured data.

* test(server): resolve Pi child fixtures as local paths on Windows

File URL pathnames retain a leading slash before a Windows drive letter. Convert fixture URLs to filesystem paths and escape those paths when inserting them into captured JSON, so child timeline assertions exercise the real file on every CI platform.

* refactor(server): remove Oh My Pi handling from the Pi provider

* fix(server): keep Pi extension adapter failures out of core session handling

* fix(server): bound Pi child-session hydration during history replay
2026-09-26 19:39:46 +02:00
paseo-ai[bot] e2de6df2e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-26 15:28:40 +00:00
225 changed files with 12261 additions and 2101 deletions
+71
View File
@@ -1,5 +1,76 @@
# Changelog
## 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
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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).
+2
View File
@@ -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.
+4
View File
@@ -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.
@@ -97,6 +99,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
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.
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.
@@ -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
+1 -1
View File
@@ -1 +1 @@
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
sha256-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
+51 -51
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.2",
"version": "0.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.2",
"version": "0.10.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -42,13 +42,6 @@
"ws": "^8.20.0"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@0no-co/graphql.web": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -14390,6 +14383,13 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -33341,6 +33341,23 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/string-length": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
@@ -33515,23 +33532,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -36410,7 +36410,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.2",
"version": "0.10.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37476,12 +37476,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.2",
"version": "0.10.2",
"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.2",
"@getpaseo/protocol": "0.10.2",
"@getpaseo/server": "0.10.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37728,10 +37728,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.2",
"version": "0.10.2",
"dependencies": {
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/protocol": "0.10.2",
"@getpaseo/relay": "0.10.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37742,7 +37742,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.2",
"version": "0.10.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37986,7 +37986,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.2",
"version": "0.10.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38882,7 +38882,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.2",
"version": "0.10.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38911,14 +38911,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.2",
"version": "0.10.2",
"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.2",
"@getpaseo/protocol": "0.10.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38928,8 +38928,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.10.2",
"@getpaseo/protocol": "0.10.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38951,7 +38951,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.2",
"version": "0.10.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38978,7 +38978,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.2",
"version": "0.10.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39196,16 +39196,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.2",
"version": "0.10.2",
"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.2",
"@getpaseo/highlight": "0.10.2",
"@getpaseo/plugin": "0.10.2",
"@getpaseo/protocol": "0.10.2",
"@getpaseo/relay": "0.10.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40110,7 +40110,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.2",
"version": "0.10.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.9.2",
"version": "0.10.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -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,
}) => {
@@ -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 { 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,
}) => {
@@ -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();
}
});
@@ -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);
}
@@ -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();
}
+36 -1
View File
@@ -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 });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.2",
"version": "0.10.2",
"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}`;
+14 -26
View File
@@ -105,7 +105,6 @@ import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHosts,
@@ -666,13 +665,11 @@ function MobileGestureWrapper({
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { upsertConnectionFromOfferUrl } = useHostMutations();
return (
<AppearanceProvider>
<VoiceProvider>
<DesktopWindowControlsSync />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<OfferLinkListener />
<HostSessionManager />
<FaviconStatusSync />
{children}
@@ -699,45 +696,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;
}
+36 -43
View File
@@ -1,18 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } 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 { 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";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -114,7 +112,8 @@ 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;
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
return raw;
return null;
}
@@ -128,11 +127,11 @@ 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 [scanError, setScanError] = useState<string | null>(null);
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
const navigateToPairedHost = useCallback(
(serverId: string) => {
@@ -160,48 +159,34 @@ export default function PairScanScreen() {
}, [permission, requestPermission]);
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
(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);
}
const store = getHostRuntimeStore();
if (passwordOfferUrl) return;
setIsPairing(true);
setScanError(null);
void store
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
.then((outcome) => {
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
else setPasswordOfferUrl(offerUrl);
return outcome;
})
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
.finally(() => setIsPairing(false));
},
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
@@ -263,10 +248,18 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} />
</View>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
</View>
</View>
)}
</View>
<PairLinkModal
visible={passwordOfferUrl !== null}
initialUrl={passwordOfferUrl ?? undefined}
initialPasswordRequired
onClose={closePasswordModal}
onSaved={savePasswordPairing}
/>
</View>
);
}
+2 -33
View File
@@ -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")}
+62 -6
View File
@@ -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 (
@@ -293,7 +297,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection } = useHostMutations();
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
useHostMutations();
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
@@ -306,6 +311,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 advancedTarget = useRef(new PairingTargetTracker("", true));
const clearInput = useCallback(() => {
setHost("");
@@ -315,6 +321,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
advancedTarget.current = new PairingTargetTracker("", true);
bumpInputResetKey();
}, []);
@@ -365,9 +372,45 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
const handleSaveRelay = useCallback(
async (relayUri: string) => {
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
relayUri,
password || undefined,
);
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,
onSaved,
password,
probeAndUpsertConnectionFromOfferUrl,
],
);
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 +465,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsSaving(false);
}
}, [
advancedUri,
daemons,
directConnectionLabels,
handleClose,
host,
isAdvancedOpen,
isMobile,
isSaving,
onSaved,
@@ -433,6 +478,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
port,
probeAndUpsertDirectConnection,
t,
handleSaveRelay,
useTls,
]);
@@ -444,6 +490,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 +510,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 +668,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}
@@ -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;
}
}
+121 -65
View File
@@ -1,4 +1,4 @@
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";
@@ -6,15 +6,27 @@ 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 { 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;
initialUrl?: string;
initialPasswordRequired?: boolean;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
@@ -60,21 +74,57 @@ export interface PairLinkModalProps {
}) => void;
}
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
export function PairLinkModal({
visible,
initialUrl,
initialPasswordRequired = false,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
return (
<PairLinkModalContent
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
visible={visible}
initialUrl={initialUrl}
initialPasswordRequired={initialPasswordRequired}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
/>
);
}
function PairLinkModalContent({
visible,
initialUrl,
initialPasswordRequired = false,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
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(initialPasswordRequired);
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 +146,63 @@ 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 { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
raw,
password || undefined,
);
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,
probeAndUpsertConnectionFromOfferUrl,
],
);
const handleChangeOfferUrl = useCallback((next: string) => {
if (targetTracker.current.changeUrl(next)) {
setPassword("");
setNeedsPassword(false);
resetPasswordInput();
setErrorMessage("");
}
offerUrlRef.current = next;
}, []);
@@ -186,6 +225,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 +241,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,
+135 -65
View File
@@ -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);
+12 -4
View File
@@ -1613,6 +1613,10 @@ export const ar: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "كلمة المرور لـ {{host}}",
label: "كلمة مرور المضيف",
},
connectionMethods: {
title: "إضافة اتصال",
direct: {
@@ -1949,8 +1953,11 @@ export const ar: TranslationResources = {
groupInfo: "حول{{title}}",
sections: {
general: "عام",
chat: "الدردشة",
appearance: "مظهر",
layout: en.settings.sections.layout,
sidebar: "الشريط الجانبي",
terminal: "الطرفية",
browser: "المتصفح",
editor: "المحرر",
shortcuts: "الاختصارات",
integrations: "التكامل",
@@ -2009,6 +2016,7 @@ export const ar: TranslationResources = {
},
general: {
title: "عام",
sending: "الإرسال",
browserData: {
title: "بيانات المتصفح",
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
@@ -2036,8 +2044,6 @@ export const ar: TranslationResources = {
},
},
serviceUrls: {
label: "عناوين URL للخدمة",
description: "مكان فتح عناوين URL من تشغيل البرامج النصية",
options: {
ask: "بسأل",
inApp: "في Paseo",
@@ -2056,7 +2062,6 @@ export const ar: TranslationResources = {
toolCallDetail: {
label: "عرض استدعاءات الأدوات",
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
options: {
overview: "ملخص",
detailed: "التفاصيل الكاملة",
@@ -2294,6 +2299,9 @@ export const ar: TranslationResources = {
},
},
host: {
password: {
guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.",
},
appearance: {
title: "المظهر",
name: {
+21 -10
View File
@@ -1638,6 +1638,10 @@ export const en = {
},
},
pairing: {
hostPassword: {
title: "Password for {{host}}",
label: "Host password",
},
connectionMethods: {
title: "Add connection",
direct: {
@@ -1974,9 +1978,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 +2001,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 +2140,7 @@ export const en = {
},
general: {
title: "General",
sending: "Sending",
browserData: {
title: "Browser data",
siteData: "Cookies and site data",
@@ -2156,8 +2167,6 @@ export const en = {
},
},
serviceUrls: {
label: "Service URLs",
description: "Where to open URLs from running scripts",
options: {
ask: "Ask",
inApp: "In Paseo",
@@ -2176,7 +2185,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 +2421,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: {
+12 -4
View File
@@ -1659,6 +1659,10 @@ export const es: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Contraseña de {{host}}",
label: "Contraseña del host",
},
connectionMethods: {
title: "Agregar conexión",
direct: {
@@ -1997,8 +2001,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 +2064,7 @@ export const es: TranslationResources = {
},
general: {
title: "General",
sending: "Envío",
browserData: {
title: "Datos del navegador",
siteData: "Cookies y datos de sitios",
@@ -2087,8 +2095,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 +2114,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 +2353,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: {
+13 -4
View File
@@ -1663,6 +1663,10 @@ export const fr: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Mot de passe pour {{host}}",
label: "Mot de passe de l’hôte",
},
connectionMethods: {
title: "Ajouter une connexion",
direct: {
@@ -2002,8 +2006,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 +2069,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 +2099,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 +2117,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 +2357,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: {
+13 -4
View File
@@ -1630,6 +1630,10 @@ export const ja: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "{{host}} のパスワード",
label: "ホストのパスワード",
},
connectionMethods: {
title: "接続を追加",
direct: {
@@ -1969,8 +1973,11 @@ export const ja: TranslationResources = {
groupInfo: "{{title}}について",
sections: {
general: "一般",
chat: "チャット",
appearance: "外観",
layout: en.settings.sections.layout,
sidebar: "サイドバー",
terminal: "ターミナル",
browser: "ブラウザ",
editor: "エディター",
shortcuts: "ショートカット",
integrations: "連携",
@@ -2029,6 +2036,7 @@ export const ja: TranslationResources = {
},
general: {
title: "一般",
sending: "送信",
browserData: {
title: "ブラウザーデータ",
siteData: "Cookie とサイトデータ",
@@ -2055,8 +2063,6 @@ export const ja: TranslationResources = {
},
},
serviceUrls: {
label: "サービスURL",
description: "実行中のスクリプトからURLを開く場所",
options: {
ask: "確認する",
inApp: "Paseoで",
@@ -2075,7 +2081,6 @@ export const ja: TranslationResources = {
toolCallDetail: {
label: "ツール呼び出しの表示",
description: "タイムラインでのツール呼び出しの表示方法",
accessibilityLabel: "ツール呼び出しの表示を選択({{value}})",
options: {
overview: "要約",
detailed: "すべての詳細",
@@ -2315,6 +2320,10 @@ export const ja: TranslationResources = {
},
},
host: {
password: {
guidance:
"このホストを削除し、このデーモンが求めるパスワードを入力して追加し直してください。",
},
appearance: {
title: "外観",
name: {
+12 -4
View File
@@ -1623,6 +1623,10 @@ export const ko: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "{{host}}의 비밀번호",
label: "호스트 비밀번호",
},
connectionMethods: {
title: "연결 추가",
direct: {
@@ -1959,8 +1963,11 @@ export const ko: TranslationResources = {
groupInfo: "{{title}} 정보",
sections: {
general: "일반",
chat: "채팅",
appearance: "모양",
layout: en.settings.sections.layout,
sidebar: "사이드바",
terminal: "터미널",
browser: "브라우저",
editor: "편집기",
shortcuts: "단축키",
integrations: "통합",
@@ -2019,6 +2026,7 @@ export const ko: TranslationResources = {
},
general: {
title: "일반",
sending: "전송",
browserData: {
title: "브라우저 데이터",
siteData: "쿠키 및 사이트 데이터",
@@ -2047,8 +2055,6 @@ export const ko: TranslationResources = {
},
},
serviceUrls: {
label: "서비스 URL",
description: "실행 중인 스크립트의 URL을 열 위치",
options: {
ask: "물어보기",
inApp: "Paseo에서",
@@ -2067,7 +2073,6 @@ export const ko: TranslationResources = {
toolCallDetail: {
label: "도구 호출 표시",
description: "타임라인에 도구 호출이 표시되는 방식",
accessibilityLabel: "도구 호출 표시 선택({{value}})",
options: {
overview: "요약",
detailed: "전체 세부정보",
@@ -2304,6 +2309,9 @@ export const ko: TranslationResources = {
},
},
host: {
password: {
guidance: "이 호스트를 제거한 뒤 이 데몬이 요구하는 비밀번호로 다시 추가하세요.",
},
appearance: {
title: "모양",
name: {
+12 -4
View File
@@ -1644,6 +1644,10 @@ export const ptBR: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Senha de {{host}}",
label: "Senha do host",
},
connectionMethods: {
title: "Adicionar conexão",
direct: {
@@ -1982,8 +1986,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 +2049,7 @@ export const ptBR: TranslationResources = {
},
general: {
title: "Geral",
sending: "Envio",
browserData: {
title: "Dados do navegador",
siteData: "Cookies e dados de sites",
@@ -2070,8 +2078,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 +2097,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 +2335,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: {
+13 -4
View File
@@ -1642,6 +1642,10 @@ export const ru: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Пароль для {{host}}",
label: "Пароль хоста",
},
connectionMethods: {
title: "Добавить подключение",
direct: {
@@ -1982,8 +1986,11 @@ export const ru: TranslationResources = {
groupInfo: "О разделе «{{title}}»",
sections: {
general: "Основные",
chat: "Чат",
appearance: "Оформление",
layout: en.settings.sections.layout,
sidebar: "Боковая панель",
terminal: "Терминал",
browser: "Браузер",
editor: "Редактор",
shortcuts: "Сочетания клавиш",
integrations: "Интеграции",
@@ -2042,6 +2049,7 @@ export const ru: TranslationResources = {
},
general: {
title: "Основные",
sending: "Отправка",
browserData: {
title: "Данные браузера",
siteData: "Файлы cookie и данные сайтов",
@@ -2071,8 +2079,6 @@ export const ru: TranslationResources = {
},
},
serviceUrls: {
label: "URL-адреса сервисов",
description: "Где открывать URL-адреса запущенных скриптов",
options: {
ask: "Спрашивать",
inApp: "В Paseo",
@@ -2092,7 +2098,6 @@ export const ru: TranslationResources = {
toolCallDetail: {
label: "Отображение вызовов инструментов",
description: "Как вызовы инструментов отображаются на временной шкале",
accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})",
options: {
overview: "Сводка",
detailed: "Все подробности",
@@ -2334,6 +2339,10 @@ export const ru: TranslationResources = {
},
},
host: {
password: {
guidance:
"Удалите этот хост и добавьте его снова с паролем, который запрашивает этот демон.",
},
appearance: {
title: "Оформление",
name: {
+12 -4
View File
@@ -1596,6 +1596,10 @@ export const zhCN: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "{{host}} 的密码",
label: "主机密码",
},
connectionMethods: {
title: "添加连接",
direct: {
@@ -1928,8 +1932,11 @@ export const zhCN: TranslationResources = {
groupInfo: "关于 {{title}}",
sections: {
general: "通用",
chat: "聊天",
appearance: "外观",
layout: en.settings.sections.layout,
sidebar: "侧边栏",
terminal: "终端",
browser: "浏览器",
editor: "编辑器",
shortcuts: "快捷键",
integrations: "集成",
@@ -1987,6 +1994,7 @@ export const zhCN: TranslationResources = {
},
general: {
title: "通用",
sending: "发送",
browserData: {
title: "浏览器数据",
siteData: "Cookie 和网站数据",
@@ -2012,8 +2020,6 @@ export const zhCN: TranslationResources = {
},
},
serviceUrls: {
label: "服务 URL",
description: "运行脚本中的 URL 打开位置",
options: {
ask: "询问",
inApp: "在 Paseo 中",
@@ -2032,7 +2038,6 @@ export const zhCN: TranslationResources = {
toolCallDetail: {
label: "工具调用显示",
description: "工具调用在时间线中的显示方式",
accessibilityLabel: "选择工具调用显示方式({{value}})",
options: {
overview: "摘要",
detailed: "完整详情",
@@ -2268,6 +2273,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) => {
+2 -1
View File
@@ -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." };
+386 -3
View File
@@ -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,31 @@ import {
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
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 +405,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 ?? {},
@@ -559,6 +584,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 +3390,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 +3418,9 @@ describe("HostRuntimeStore", () => {
type: "directTcp",
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
},
]);
expect(host?.password).toBe("shared-secret");
store.syncHosts([]);
});
@@ -3557,6 +3597,33 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("probes a pairing link immediately and saves only after admission", async () => {
const store = new HostRuntimeStore({
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: "paired host",
};
},
getClientId: async () => "cid_pairing",
},
});
const offerUrl = encodeOfferUrl(makeOffer());
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
"Password required",
);
expect(store.getHosts()).toHaveLength(0);
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
expect(result.serverId).toBe("srv_offer");
expect(store.getHosts()[0]?.password).toBe("correct-password");
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
@@ -3625,6 +3692,322 @@ 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 pairing link after a password retry in the add flow", 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: "paired",
};
},
getClientId: async () => "cid_link_import",
},
});
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
status: "password_required",
});
expect(store.getHosts()).toHaveLength(0);
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_pair",
password: "correct-password",
});
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",
},
});
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({
+176 -20
View File
@@ -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,
@@ -23,13 +24,14 @@ 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 { 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 +45,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,6 +85,7 @@ 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";
export type ActiveConnection =
@@ -106,6 +110,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 +522,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 +557,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
url: buildDaemonWebSocketUrl(connection.endpoint, {
useTls: connection.useTls ?? false,
}),
...(connection.password ? { password: connection.password } : {}),
});
}
return new DaemonClient({
@@ -570,6 +576,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 +625,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 +714,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 +729,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 +825,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 +861,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 +912,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 +1056,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 +1080,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 +1295,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 +1306,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 +1337,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 +1407,19 @@ function readConfiguredLocalDaemonOverride(): string | null {
return value && value.length > 0 ? value : null;
}
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;
}
@@ -1495,6 +1570,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 +1800,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 +1813,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 +1822,7 @@ export class HostRuntimeStore {
}
const probeHost: HostProfile = {
serverId: "",
...(input.password ? { password: input.password } : {}),
label: input.label ?? input.connection.id,
appearance: defaultHostAppearance(),
lifecycle: {},
@@ -1754,6 +1839,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 +1856,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 } : {}),
},
});
}
@@ -1798,6 +1884,7 @@ export class HostRuntimeStore {
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
password?: string;
}): Promise<HostProfile> {
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
const useTls = input.useTls ?? false;
@@ -1806,7 +1893,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,9 +1904,18 @@ 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> {
async upsertConnectionFromOffer(
offer: ConnectionOffer,
label?: string,
password?: string,
): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({
@@ -1828,25 +1924,67 @@ export class HostRuntimeStore {
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
password,
});
}
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
password?: string,
): Promise<HostProfile> {
const marker = "#offer=";
const idx = offerUrlOrFragment.indexOf(marker);
if (idx === -1) {
throw new Error("Missing #offer= fragment");
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
}
async probeAndUpsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
password?: string,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
const offer = parsed.offer;
const credential = password ?? parsed.password;
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
const connection: HostConnection = {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
};
const probeHost: HostProfile = {
serverId: offer.serverId,
...(credential ? { password: credential } : {}),
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, credential);
return { profile, serverId: offer.serverId, hostname };
}
async importConnectionLink(
url: string,
target: PairingNavigationTarget,
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
if (target === "openProject") {
const profile = await this.upsertConnectionFromOfferUrl(url);
return { status: "connected", serverId: profile.serverId };
}
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
if (!encoded) {
throw new Error("Offer payload is empty");
try {
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
return { status: "connected", serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
return { status: "password_required" };
}
const payload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(payload);
return this.upsertConnectionFromOffer(offer, label);
}
async upsertConnectionFromListen(input: {
@@ -1886,6 +2024,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 +2108,7 @@ export class HostRuntimeStore {
private async upsertHostConnection(input: {
serverId: string;
label?: string;
password?: string;
connection: HostConnection;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
@@ -1972,6 +2118,7 @@ export class HostRuntimeStore {
serverId: input.serverId,
label: input.label,
connection: input.connection,
password: input.password,
now,
});
this.setHostsAndSync(next, {
@@ -2605,12 +2752,18 @@ export interface HostMutations {
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
password?: string;
}) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
label?: string,
password?: string,
) => Promise<HostProfile>;
probeAndUpsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
password?: string,
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
@@ -2627,7 +2780,10 @@ export function useHostMutations(): HostMutations {
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
upsertConnectionFromOfferUrl: (url, label, password) =>
store.upsertConnectionFromOfferUrl(url, label, password),
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
store.probeAndUpsertConnectionFromOfferUrl(url, password),
renameHost: (serverId, label) => store.renameHost(serverId, label),
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
setHostBadgeDisplay: (serverId, badgeDisplay) =>
@@ -419,7 +419,7 @@ function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps)
});
return (
<View style={styles.errorBlock}>
<Alert testID={testID} variant="error" title={title} description={description}>
<Alert size="md" testID={testID} variant="error" title={title} description={description}>
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
{t("settings.project.actions.reload")}
</Button>
@@ -702,6 +702,7 @@ function ProjectConfigForm({
>
{hasUncommittedWorktreeSetupChanges ? (
<Alert
size="sm"
variant="warning"
title={t("settings.project.worktree.uncommittedTitle")}
description={t("settings.project.worktree.uncommittedDescription")}
@@ -776,6 +777,7 @@ function ProjectConfigForm({
{isStale ? (
<View style={styles.calloutWrap}>
<Alert
size="sm"
testID="stale-callout"
variant="error"
title={t("settings.project.writeFailures.staleTitle")}
@@ -796,6 +798,7 @@ function ProjectConfigForm({
{isWriteFailed ? (
<View style={styles.calloutWrap}>
<Alert
size="sm"
testID="write-failed-callout"
variant="error"
title={t("settings.project.writeFailures.failedTitle")}
+174 -372
View File
@@ -8,7 +8,6 @@ import {
View,
type PressableStateCallbackType,
} from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -38,27 +37,27 @@ import {
Smartphone,
Sparkles,
Blocks,
PanelsTopLeft,
Globe,
PanelLeft,
MessageSquare,
ChevronRight,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker";
import { HostStatusDot } from "@/components/host-status-dot";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
import { LayoutSection } from "@/screens/settings/layout/layout-section";
import { OpenLocationSection } from "@/screens/settings/open-location/open-location-section";
import { TerminalSection } from "@/screens/settings/terminal/terminal-section";
import { ChatSection } from "@/screens/settings/chat/chat-section";
import { SidebarNavSection } from "@/screens/settings/sidebar/sidebar-nav-section";
import { SendingSection } from "@/screens/settings/general/sending-section";
import {
useAppSettings,
useSettings,
parseTerminalScrollbackLines,
type AppSettings,
type SendBehavior,
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
@@ -114,7 +113,6 @@ import {
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
import { resolvePluginIcon } from "@/plugins/icons";
import { PluginSettingsContent } from "@/plugins/settings";
import { useInstalledPlugins } from "@/plugins/registry";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
@@ -144,48 +142,88 @@ import { isNative, isWeb } from "@/constants/platform";
interface SidebarSectionItem {
id: SettingsSectionSlug;
labelKey: string;
icon: ComponentType<{ size: number; color: string }>;
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
desktopOnly?: boolean;
webOnly?: boolean;
/** The page body, for pages that need nothing from the settings screen. */
Content?: ComponentType;
}
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
{
id: "layout",
labelKey: "settings.sections.layout",
icon: PanelsTopLeft,
id: "appearance",
labelKey: "settings.sections.appearance",
icon: Palette,
Content: AppearanceSection,
},
{
id: "sidebar",
labelKey: "settings.sections.sidebar",
icon: PanelLeft,
Content: SidebarNavSection,
},
{ id: "chat", labelKey: "settings.sections.chat", icon: MessageSquare, Content: ChatSection },
{
id: "terminal",
labelKey: "settings.sections.terminal",
icon: SquareTerminal,
Content: TerminalSection,
},
{
id: "browser",
labelKey: "settings.sections.browser",
icon: Globe,
desktopOnly: true,
Content: BrowserDataSection,
},
{
id: "editor",
labelKey: "settings.sections.editor",
icon: Code2,
webOnly: true,
Content: EditorSection,
},
{
id: "shortcuts",
labelKey: "settings.sections.shortcuts",
icon: Keyboard,
desktopOnly: true,
Content: KeyboardShortcutsSection,
},
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2, webOnly: true },
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
{
id: "integrations",
labelKey: "settings.sections.integrations",
icon: Puzzle,
desktopOnly: true,
Content: IntegrationsSection,
},
{
id: "notifications",
labelKey: "settings.sections.notifications",
icon: Bell,
desktopOnly: true,
Content: DesktopNotificationsSection,
},
{
id: "permissions",
labelKey: "settings.sections.permissions",
icon: Shield,
desktopOnly: true,
Content: DesktopPermissionsSection,
},
{ id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope },
{ id: "about", labelKey: "settings.sections.about", icon: Info },
];
function isSectionAvailable(item: SidebarSectionItem, isDesktopApp: boolean): boolean {
return (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb);
}
interface HostSectionItem {
id: HostSectionSlug;
labelKey: string;
icon: ComponentType<{ size: number; color: string }>;
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
}
const HOST_SECTION_ITEMS: HostSectionItem[] = [
@@ -236,10 +274,6 @@ function renderHostSettingsContent(
// Trigger + sidebar style helpers
// ---------------------------------------------------------------------------
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
}
function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered];
}
@@ -252,82 +286,18 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
];
}
function getSendBehaviorOptions(t: TFunction) {
return [
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
{ value: "steer" as const, label: t("settings.general.defaultSend.options.steer") },
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
];
}
function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string {
const labels: Record<ServiceUrlBehavior, string> = {
ask: t("settings.general.serviceUrls.options.ask"),
"in-app": t("settings.general.serviceUrls.options.inApp"),
external: t("settings.general.serviceUrls.options.external"),
};
return labels[value];
}
function getActiveLocale(language: string | undefined): SupportedLocale {
const parsed = parseAppLanguage(language);
return parsed && parsed !== "system" ? parsed : "en";
}
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
// ---------------------------------------------------------------------------
// Section components
// ---------------------------------------------------------------------------
interface GeneralSectionProps {
settings: AppSettings;
isDesktopApp: boolean;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
handleLanguageChange: (language: AppLanguage) => void;
handleTerminalScrollbackLinesChange: (lines: number) => void;
}
interface ServiceUrlBehaviorMenuItemProps {
value: ServiceUrlBehavior;
label: string;
selected: boolean;
onChange: (value: ServiceUrlBehavior) => void;
}
interface SendBehaviorMenuItemProps {
value: SendBehavior;
label: string;
selected: boolean;
onChange: (value: SendBehavior) => void;
}
function SendBehaviorMenuItem({ value, label, selected, onChange }: SendBehaviorMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(value);
}, [onChange, value]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
function ServiceUrlBehaviorMenuItem({
value,
label,
selected,
onChange,
}: ServiceUrlBehaviorMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(value);
}, [onChange, value]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
interface LanguageMenuItemProps {
@@ -354,21 +324,9 @@ function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageM
);
}
function GeneralSection({
settings,
isDesktopApp,
handleSendBehaviorChange,
handleServiceUrlBehaviorChange,
handleLanguageChange,
handleTerminalScrollbackLinesChange,
}: GeneralSectionProps) {
function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) {
const { t, i18n } = useTranslation();
const activeLocale = getActiveLocale(i18n.language);
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
const selectedSendBehaviorLabel =
sendBehaviorOptions.find((option) => option.value === settings.sendBehavior)?.label ??
settings.sendBehavior;
const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`;
const selectedLanguageOption = LANGUAGE_OPTIONS.find(
(option) => option.value === settings.language,
);
@@ -379,72 +337,17 @@ function GeneralSection({
t(selectedLanguageOption.labelKey),
)
: settings.language;
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
String(settings.terminalScrollbackLines),
);
const handleTerminalScrollbackChangeText = useCallback((value: string) => {
setTerminalScrollbackValue(value.replace(/[^\d]/g, ""));
}, []);
const commitTerminalScrollback = useCallback(() => {
const parsed = parseTerminalScrollbackLines(terminalScrollbackValue);
const nextValue = parsed ?? settings.terminalScrollbackLines;
setTerminalScrollbackValue(String(nextValue));
if (nextValue !== settings.terminalScrollbackLines) {
handleTerminalScrollbackLinesChange(nextValue);
}
}, [
handleTerminalScrollbackLinesChange,
settings.terminalScrollbackLines,
terminalScrollbackValue,
]);
useEffect(() => {
setTerminalScrollbackValue(String(settings.terminalScrollbackLines));
}, [settings.terminalScrollbackLines]);
return (
<SettingsSection title={t("settings.general.title")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
<Text style={settingsStyles.rowHint}>{t(sendBehaviorDescriptionKey)}</Text>
</View>
<DropdownMenu>
<DropdownTrigger
accessibilityRole="button"
accessibilityLabel={`${t("settings.general.defaultSend.label")}: ${selectedSendBehaviorLabel}`}
style={themeTriggerStyle}
>
<Text style={styles.themeTriggerText}>{selectedSendBehaviorLabel}</Text>
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{sendBehaviorOptions.map((option) => (
<SendBehaviorMenuItem
key={option.value}
value={option.value}
label={option.label}
selected={settings.sendBehavior === option.value}
onChange={handleSendBehaviorChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.language.label")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.general.language.description")}</Text>
</View>
<DropdownMenu>
<DropdownTrigger
accessibilityRole="button"
accessibilityLabel={selectedLanguageLabel}
style={themeTriggerStyle}
>
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
<DropdownTrigger accessibilityRole="button" accessibilityLabel={selectedLanguageLabel}>
{selectedLanguageLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={300}>
{LANGUAGE_OPTIONS.map((option) => (
@@ -459,55 +362,6 @@ function GeneralSection({
</DropdownMenuContent>
</DropdownMenu>
</View>
{isDesktopApp ? (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.serviceUrls.label")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.serviceUrls.description")}
</Text>
</View>
<DropdownMenu>
<DropdownTrigger style={themeTriggerStyle}>
<Text style={styles.themeTriggerText}>
{getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)}
</Text>
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
<ServiceUrlBehaviorMenuItem
key={value}
value={value}
label={getServiceUrlBehaviorLabel(t, value)}
selected={settings.serviceUrlBehavior === value}
onChange={handleServiceUrlBehaviorChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.terminalScrollback.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.terminalScrollback.description")}
</Text>
</View>
<TextInput
initialValue={terminalScrollbackValue}
onChangeText={handleTerminalScrollbackChangeText}
onBlur={commitTerminalScrollback}
onSubmitEditing={commitTerminalScrollback}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.terminalScrollbackInput}
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
/>
</View>
</View>
</SettingsSection>
);
@@ -889,11 +743,14 @@ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): Hos
interface SidebarSectionButtonProps {
itemId: SettingsSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
isSelected: boolean;
onSelect: (section: SettingsSectionSlug) => void;
}
// Lighter than lucide's default 2px stroke so the nav reads quieter than the content.
const SIDEBAR_ICON_STROKE_WIDTH = 1.5;
function SidebarSectionButton({
itemId,
label,
@@ -920,6 +777,7 @@ function SidebarSectionButton({
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
@@ -931,7 +789,7 @@ function SidebarSectionButton({
interface SidebarHostSectionButtonProps {
itemId: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
isSelected: boolean;
onSelect: (section: HostSectionSlug) => void;
}
@@ -963,6 +821,7 @@ function SidebarHostSectionButton({
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
@@ -1082,9 +941,7 @@ function SettingsSidebar({
const hasHosts = sortedHosts.length > 0;
const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption();
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter(
(item) => (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb),
);
const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp));
const insets = useSafeAreaInsets();
const isDesktop = layout === "desktop";
const outerContainerStyle = useMemo(
@@ -1116,7 +973,6 @@ function SettingsSidebar({
/>
))}
</View>
<SidebarSeparator />
{hasHosts ? (
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
@@ -1147,7 +1003,11 @@ function SettingsSidebar({
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Plus
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
<Text style={sidebarStyles.label} numberOfLines={1}>
{t("settings.addHost")}
</Text>
@@ -1160,7 +1020,11 @@ function SettingsSidebar({
testID="settings-enable-built-in-daemon"
style={sidebarItemStyle}
>
<Server size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Server
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
/>
<Text style={sidebarStyles.label} numberOfLines={1}>
{t("settings.enableBuiltInDaemon")}
</Text>
@@ -1216,7 +1080,6 @@ export interface SettingsScreenProps {
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
const router = useRouter();
const { theme } = useUnistyles();
const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
@@ -1265,20 +1128,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
});
}, [view, selectedSettingsHostServerId, localServerId, hosts, sortedHosts]);
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
},
[updateSettings],
);
const handleServiceUrlBehaviorChange = useCallback(
(behavior: ServiceUrlBehavior) => {
void updateSettings({ serviceUrlBehavior: behavior });
},
[updateSettings],
);
const handleLanguageChange = useCallback(
(language: AppLanguage) => {
void updateSettings({ language });
@@ -1286,13 +1135,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[updateSettings],
);
const handleTerminalScrollbackLinesChange = useCallback(
(terminalScrollbackLines: number) => {
void updateSettings({ terminalScrollbackLines });
},
[updateSettings],
);
const handleUseLegacyTerminalRendererChange = useCallback(
(useLegacyTerminalRenderer: boolean) => {
void updateSettings({ useLegacyTerminalRenderer });
@@ -1465,112 +1307,89 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const installedPlugins = useInstalledPlugins();
const detailHeader = ((): {
title: string;
Icon: ComponentType<{ size: number; color: string }>;
titleAccessory?: ReactNode;
} | null => {
if (view.kind === "plugin") {
const screen = installedPlugins
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
return {
title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}`,
Icon: screen ? resolvePluginIcon(screen.icon) : Blocks,
};
return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` };
}
if (view.kind === "host") {
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null;
return { title: t(item.labelKey), Icon: item.icon };
return { title: t(item.labelKey) };
}
if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null;
return { title: t(item.labelKey), Icon: item.icon };
return { title: t(item.labelKey) };
}
if (view.kind === "project") {
return { title: t("settings.projects"), Icon: FolderGit2 };
return { title: t("settings.projects") };
}
return null;
})();
let content: ReactNode;
if (view.kind === "section" && view.section === "layout") {
content = isDesktopApp ? <LayoutSection /> : null;
} else {
content = (() => {
if (view.kind === "plugin")
return (
<PluginSettingsContent
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
/>
);
if (view.kind === "host") {
return renderHostSettingsContent(view, handleHostRemoved);
const content: ReactNode = (() => {
if (view.kind === "plugin")
return (
<PluginSettingsContent
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
/>
);
if (view.kind === "host") {
return renderHostSettingsContent(view, handleHostRemoved);
}
if (view.kind === "project") {
return (
<ProjectSettingsScreen
serverId={view.serverId}
projectId={view.projectId}
onBackToProjects={handleBackFromDetail}
showBackToProjects={!isCompactLayout}
/>
);
}
if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section);
if (!item || !isSectionAvailable(item, isDesktopApp)) return null;
switch (view.section) {
case "general":
return (
<>
<GeneralSection settings={settings} handleLanguageChange={handleLanguageChange} />
<SendingSection />
{isDesktopApp ? <OpenLocationSection /> : null}
</>
);
case "diagnostics":
return (
<DiagnosticsSection
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
voiceAudioEngine={voiceAudioEngine}
isPlaybackTestRunning={isPlaybackTestRunning}
playbackTestResult={playbackTestResult}
handlePlaybackTest={handlePlaybackTest}
/>
);
case "about":
return (
<AboutSection
appVersion={appVersion}
appVersionText={appVersionText}
isDesktopApp={isDesktopApp}
/>
);
default:
return item.Content ? <item.Content /> : null;
}
if (view.kind === "project") {
return (
<ProjectSettingsScreen
serverId={view.serverId}
projectId={view.projectId}
onBackToProjects={handleBackFromDetail}
showBackToProjects={!isCompactLayout}
/>
);
}
if (view.kind === "section") {
switch (view.section) {
case "general":
return (
<>
<GeneralSection
settings={settings}
isDesktopApp={isDesktopApp}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleLanguageChange={handleLanguageChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
{isDesktopApp ? <BrowserDataSection /> : null}
</>
);
case "appearance":
return <AppearanceSection />;
case "editor":
return isWeb ? <EditorSection /> : null;
case "shortcuts":
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null;
case "notifications":
return isDesktopApp ? <DesktopNotificationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "diagnostics":
return (
<DiagnosticsSection
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
voiceAudioEngine={voiceAudioEngine}
isPlaybackTestRunning={isPlaybackTestRunning}
playbackTestResult={playbackTestResult}
handlePlaybackTest={handlePlaybackTest}
/>
);
case "about":
return (
<AboutSection
appVersion={appVersion}
appVersionText={appVersionText}
isDesktopApp={isDesktopApp}
/>
);
}
}
return null;
})();
}
}
return null;
})();
if (settingsLoading) {
return (
@@ -1580,14 +1399,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
);
}
const desktopDetailHeaderLeft = detailHeader ? (
<>
<HeaderIconBadge>
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</HeaderIconBadge>
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
const desktopPageTitle = detailHeader ? (
<View style={styles.pageTitleRow}>
<Text style={styles.pageTitle} testID="settings-detail-header-title">
{detailHeader.title}
</Text>
{detailHeader.titleAccessory}
</>
</View>
) : null;
const addHostModals = (
@@ -1679,13 +1497,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</WindowChromeRegion>
<WindowChromeRegion corners="top-right">
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
<ScreenHeader
borderless={!detailHeader}
left={desktopDetailHeaderLeft}
leftStyle={desktopStyles.detailLeft}
/>
{/* Keeps the titlebar drag region and window controls; the page
title lives in the content, like a document heading. */}
<ScreenHeader borderless />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
<View style={styles.content}>
{desktopPageTitle}
{content}
</View>
</ScrollView>
</View>
</WindowChromeRegion>
@@ -1724,6 +1543,19 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: 720,
alignSelf: "center",
},
pageTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
// Line up with the section titles, which sit inset from their cards.
marginLeft: theme.spacing[1],
marginBottom: theme.spacing[6],
},
pageTitle: {
fontSize: theme.fontSize["4xl"],
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -1744,33 +1576,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
themeTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
},
themeTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
terminalScrollbackInput: {
width: 112,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
textAlign: "right",
},
placeholder: {
flex: 1,
alignItems: "center",
@@ -1783,7 +1588,7 @@ const styles = StyleSheet.create((theme) => ({
},
}));
const desktopStyles = StyleSheet.create((theme) => ({
const desktopStyles = StyleSheet.create({
row: {
flex: 1,
flexDirection: "row",
@@ -1791,10 +1596,7 @@ const desktopStyles = StyleSheet.create((theme) => ({
contentPane: {
flex: 1,
},
detailLeft: {
gap: theme.spacing[2],
},
}));
});
const sidebarStyles = StyleSheet.create((theme) => ({
desktopContainer: {
@@ -1816,12 +1618,12 @@ const sidebarStyles = StyleSheet.create((theme) => ({
list: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
gap: theme.spacing[1],
gap: theme.spacing[0.5],
},
groupLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundExtraMuted,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
@@ -1829,8 +1631,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
paddingVertical: theme.spacing[2],
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
@@ -1850,8 +1652,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
paddingVertical: theme.spacing[2],
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
@@ -1,9 +1,9 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
import { Monitor, Moon, Sun } from "lucide-react-native";
import {
SYNTAX_THEME_OPTIONS,
type SyntaxThemeId,
@@ -14,9 +14,8 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsCard, SettingsSwitch } from "@/components/settings";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useContributedThemes } from "@/appearance/provider";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
@@ -46,7 +45,6 @@ import { isNative } from "@/constants/platform";
import type { PluginThemeOption } from "@/plugins/themes";
import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview";
import { SidebarNavSection } from "./sidebar-nav-section";
// ---------------------------------------------------------------------------
// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no
@@ -57,7 +55,6 @@ import { SidebarNavSection } from "./sidebar-nav-section";
const ThemedSun = withUnistyles(Sun);
const ThemedMoon = withUnistyles(Moon);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -83,10 +80,6 @@ function sizeDraftToOverride(value: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
return [styles.trigger, pressed ? styles.triggerPressed : null];
}
// ---------------------------------------------------------------------------
// Theme picker
// ---------------------------------------------------------------------------
@@ -175,26 +168,29 @@ function ThemeRow({
const selectedLabel = selectedPluginTheme
? selectedPluginTheme.name
: getThemeLabel(t, builtInValue);
const leading = useMemo(
() =>
selectedPluginTheme ? (
<ThemeSwatch color={selectedPluginTheme.swatch} />
) : (
<ThemeLeading themeValue={builtInValue} />
),
[builtInValue, selectedPluginTheme],
);
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.theme.title")}</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
<DropdownTrigger
accessibilityLabel={t("settings.appearance.theme.accessibilityLabel", {
value: selectedLabel,
})}
leading={leading}
>
{selectedPluginTheme ? (
<ThemeSwatch color={selectedPluginTheme.swatch} />
) : (
<ThemeLeading themeValue={builtInValue} />
)}
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
{THEME_OPTIONS.map((option, index) => {
const previousOption = THEME_OPTIONS[index - 1];
@@ -226,109 +222,6 @@ function ThemeRow({
);
}
interface AutoExpandReasoningRowProps {
value: boolean;
onChange: (value: boolean) => void;
}
function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) {
const { t } = useTranslation();
return (
<SettingsSwitch
label={t("settings.general.autoExpandReasoning.label")}
hint={t("settings.general.autoExpandReasoning.description")}
value={value}
onValueChange={onChange}
/>
);
}
interface ChatOutlineRowProps {
value: boolean;
onChange: (value: boolean) => void;
}
function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) {
const { t } = useTranslation();
return (
<SettingsSwitch
label={t("settings.appearance.chatOutline.title")}
hint={t("settings.appearance.chatOutline.description")}
value={value}
onValueChange={onChange}
/>
);
}
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
"detailed",
"overview",
];
function getToolCallDetailLevelLabel(
t: TFunction,
value: AppSettings["toolCallDetailLevel"],
): string {
return t(`settings.general.toolCallDetail.options.${value}`);
}
interface ToolCallDetailMenuItemProps {
value: AppSettings["toolCallDetailLevel"];
selected: boolean;
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
}
function ToolCallDetailMenuItem({ value, selected, onChange }: ToolCallDetailMenuItemProps) {
const { t } = useTranslation();
const handleSelect = useCallback(() => onChange(value), [onChange, value]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{getToolCallDetailLevelLabel(t, value)}
</DropdownMenuItem>
);
}
interface ToolCallDetailRowProps {
value: AppSettings["toolCallDetailLevel"];
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
}
function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) {
const { t } = useTranslation();
const selectedLabel = getToolCallDetailLevelLabel(t, value);
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.toolCallDetail.label")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.toolCallDetail.description")}
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityLabel={t("settings.general.toolCallDetail.accessibilityLabel", {
value: selectedLabel,
})}
>
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{TOOL_CALL_DETAIL_LEVELS.map((option) => (
<ToolCallDetailMenuItem
key={option}
value={option}
selected={value === option}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// Fonts: family text fields + numeric size fields (commit on blur/submit)
// ---------------------------------------------------------------------------
@@ -478,15 +371,13 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
<DropdownTrigger
accessibilityLabel={t("settings.appearance.syntax.highlightThemeAccessibility", {
value: selectedLabel,
})}
>
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{SYNTAX_THEME_OPTIONS.map((option) => (
<SyntaxMenuItem
@@ -556,27 +447,6 @@ export function AppearanceSection() {
[updateSettings],
);
const handleAutoExpandReasoningChange = useCallback(
(autoExpandReasoning: boolean) => {
void updateSettings({ autoExpandReasoning });
},
[updateSettings],
);
const handleToolCallDetailLevelChange = useCallback(
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => {
void updateSettings({ toolCallDetailLevel });
},
[updateSettings],
);
const handleChatOutlineChange = useCallback(
(chatOutlineEnabled: boolean) => {
void updateSettings({ chatOutlineEnabled });
},
[updateSettings],
);
const commitUiFontFamily = useCallback(
(value: string) => {
const sanitized = sanitizeFontFamily(value);
@@ -680,25 +550,6 @@ export function AppearanceSection() {
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
<SettingsCard>
<AutoExpandReasoningRow
value={settings.autoExpandReasoning}
onChange={handleAutoExpandReasoningChange}
/>
<ToolCallDetailRow
value={settings.toolCallDetailLevel}
onChange={handleToolCallDetailLevelChange}
/>
{!isNative ? (
<ChatOutlineRow
value={settings.chatOutlineEnabled}
onChange={handleChatOutlineChange}
/>
) : null}
</SettingsCard>
</SettingsSection>
<SidebarNavSection />
<SettingsSection title={t("settings.appearance.fonts.title")}>
<View style={settingsStyles.card}>
{showInterfaceFontFamilyRow ? (
@@ -777,23 +628,6 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
triggerPressed: {
opacity: 0.85,
},
triggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
swatch: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
@@ -0,0 +1,74 @@
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import {
SettingsCard,
SettingsSection,
SettingsSelect,
SettingsSwitch,
} from "@/components/settings";
import { isNative } from "@/constants/platform";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
"detailed",
"overview",
];
export function ChatSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const toolCallDetailOptions = useMemo(
() =>
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
value,
label: t(`settings.general.toolCallDetail.options.${value}`),
})),
[t],
);
const changeToolCallDetailLevel = useCallback(
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
void updateSettings({ toolCallDetailLevel }),
[updateSettings],
);
const changeAutoExpandReasoning = useCallback(
(autoExpandReasoning: boolean) => void updateSettings({ autoExpandReasoning }),
[updateSettings],
);
const changeChatOutline = useCallback(
(chatOutlineEnabled: boolean) => void updateSettings({ chatOutlineEnabled }),
[updateSettings],
);
return (
<View>
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
<SettingsCard>
<SettingsSwitch
label={t("settings.general.autoExpandReasoning.label")}
hint={t("settings.general.autoExpandReasoning.description")}
value={settings.autoExpandReasoning}
onValueChange={changeAutoExpandReasoning}
/>
<SettingsSelect
label={t("settings.general.toolCallDetail.label")}
hint={t("settings.general.toolCallDetail.description")}
value={settings.toolCallDetailLevel}
options={toolCallDetailOptions}
onValueChange={changeToolCallDetailLevel}
/>
{isNative ? null : (
<SettingsSwitch
label={t("settings.appearance.chatOutline.title")}
hint={t("settings.appearance.chatOutline.description")}
value={settings.chatOutlineEnabled}
onValueChange={changeChatOutline}
/>
)}
</SettingsCard>
</SettingsSection>
</View>
);
}
@@ -0,0 +1,36 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
export function SendingSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const options = useMemo(
() =>
SEND_BEHAVIORS.map((value) => ({
value,
label: t(`settings.general.defaultSend.options.${value}`),
})),
[t],
);
const change = useCallback(
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
[updateSettings],
);
return (
<SettingsSection title={t("settings.general.sending")}>
<SettingsCard>
<SettingsSelect
label={t("settings.general.defaultSend.label")}
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
value={settings.sendBehavior}
options={options}
onValueChange={change}
/>
</SettingsCard>
</SettingsSection>
);
}
@@ -1,15 +1,11 @@
import { useCallback, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, Pencil } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Pencil } from "lucide-react-native";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row";
import { useToast } from "@/contexts/toast-context";
@@ -28,15 +24,10 @@ import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedPencil = withUnistyles(Pencil);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
return pressed ? [styles.trigger, styles.triggerPressed] : styles.trigger;
}
function HostRenameButton({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { renameHost } = useHostMutations();
@@ -119,23 +110,22 @@ function ColorMenuItem({
function ColorRow({ color, onChange }: { color: HostColor; onChange: (color: HostColor) => void }) {
const { t } = useTranslation();
const selectedLabel = colorLabel(t, color);
const leading = useMemo(() => <ColorSwatch color={color} />, [color]);
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.color.label")}</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
<DropdownTrigger
accessibilityRole="button"
accessibilityLabel={t("settings.host.appearance.color.accessibilityLabel", {
value: selectedLabel,
})}
leading={leading}
>
<ColorSwatch color={color} />
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{HOST_COLORS.map((option) => (
<ColorMenuItem
@@ -166,17 +156,15 @@ function BadgeDisplayRow({
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.badge.label")}</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
<DropdownTrigger
testID="host-appearance-badge-display"
style={dropdownTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.host.appearance.badge.accessibilityLabel", {
value: selectedLabel,
})}
>
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{HOST_BADGE_DISPLAYS.map((option) => (
<BadgeDisplayMenuItem
@@ -312,23 +300,6 @@ export function HostAppearanceSection({ host }: { host: HostProfile }) {
}
const styles = StyleSheet.create((theme) => ({
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
triggerPressed: {
opacity: 0.85,
},
triggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
swatch: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
+17 -23
View File
@@ -226,12 +226,23 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
}
function HostConnectionError({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const snapshot = useHostRuntimeSnapshot(serverId);
const lastError = snapshot?.lastError ?? null;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
if (!connectionError) return null;
return <Text style={styles.errorText}>{connectionError}</Text>;
return (
<View style={styles.connectionError}>
<InlineAlert
size="sm"
variant="error"
title={connectionError}
description={snapshot?.authFailureReason ? t("settings.host.password.guidance") : undefined}
testID="host-connection-error"
/>
</View>
);
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
@@ -364,13 +375,8 @@ export function HostSettingsPage({
return (
<View>
<View style={styles.daemonHeader}>
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
{host.label}
</Text>
</View>
<HostStatusBadges serverId={serverId} />
<HostConnectionError serverId={serverId} />
<HostAppearanceSection host={host} />
@@ -835,6 +841,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
</View>
{updateState.status === "complete" ? (
<InlineAlert
size="sm"
variant="success"
title={t("desktop.daemon.lifecycle.workerUpdated", {
version: updateState.workerVersion,
@@ -845,6 +852,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
{updateState.status === "failed" ? (
<View style={styles.updateFailure}>
<InlineAlert
size="sm"
variant="error"
title={updateState.title}
description={updateState.message}
@@ -1700,18 +1708,6 @@ const styles = StyleSheet.create((theme) => ({
marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[4],
},
daemonHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginBottom: theme.spacing[4],
},
daemonHeaderLabel: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
identityBadges: {
flexDirection: "row",
alignItems: "center",
@@ -1742,10 +1738,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[2],
connectionError: {
marginBottom: theme.spacing[6],
},
connectionLatency: {
fontSize: theme.fontSize.base,
@@ -5,6 +5,7 @@ import {
useAppSettings,
type OpenInSidePanePreferences,
type PullRequestOpenLocation,
type ServiceUrlBehavior,
} from "@/hooks/use-settings";
const SOURCES = [
@@ -15,18 +16,26 @@ const SOURCES = [
"subagents",
] as const satisfies readonly (keyof OpenInSidePanePreferences)[];
type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests";
const SERVICE_URL_BEHAVIORS: readonly ServiceUrlBehavior[] = ["ask", "in-app", "external"];
function LayoutPreferenceRow({
const SERVICE_URL_LABEL_KEYS: Record<ServiceUrlBehavior, string> = {
ask: "settings.general.serviceUrls.options.ask",
"in-app": "settings.general.serviceUrls.options.inApp",
external: "settings.general.serviceUrls.options.external",
};
type OpenLocationSource = keyof OpenInSidePanePreferences | "pullRequests";
function OpenLocationRow({
source,
destination,
allowExplorer,
onDestinationChange,
}: {
source: LayoutPreferenceSource;
source: OpenLocationSource;
destination: PullRequestOpenLocation;
allowExplorer?: boolean;
onDestinationChange(source: LayoutPreferenceSource, destination: PullRequestOpenLocation): void;
onDestinationChange(source: OpenLocationSource, destination: PullRequestOpenLocation): void;
}) {
const { t } = useTranslation();
const options = useMemo(() => {
@@ -52,11 +61,34 @@ function LayoutPreferenceRow({
);
}
export function LayoutSection() {
function ServiceUrlRow() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const options = useMemo(
() =>
SERVICE_URL_BEHAVIORS.map((value) => ({ value, label: t(SERVICE_URL_LABEL_KEYS[value]) })),
[t],
);
const change = useCallback(
(serviceUrlBehavior: ServiceUrlBehavior) => void updateSettings({ serviceUrlBehavior }),
[updateSettings],
);
return (
<SettingsSelect
label={t("settings.layout.openInSidePane.sources.serviceUrls.label")}
value={settings.serviceUrlBehavior}
options={options}
onValueChange={change}
/>
);
}
/** Where things open: files, diffs, subagents, pull requests, and script URLs. Desktop only. */
export function OpenLocationSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const handleDestinationChange = useCallback(
(source: LayoutPreferenceSource, destination: PullRequestOpenLocation) => {
(source: OpenLocationSource, destination: PullRequestOpenLocation) => {
if (source === "pullRequests") {
void updateSettings({ pullRequestOpenLocation: destination });
return;
@@ -71,19 +103,20 @@ export function LayoutSection() {
<SettingsSection title={t("settings.layout.openInSidePane.title")}>
<SettingsCard>
{SOURCES.map((source) => (
<LayoutPreferenceRow
<OpenLocationRow
key={source}
source={source}
destination={settings.openInSidePane[source] ? "side" : "main"}
onDestinationChange={handleDestinationChange}
/>
))}
<LayoutPreferenceRow
<OpenLocationRow
source="pullRequests"
destination={settings.pullRequestOpenLocation}
allowExplorer
onDestinationChange={handleDestinationChange}
/>
<ServiceUrlRow />
</SettingsCard>
</SettingsSection>
);
@@ -230,6 +230,7 @@ function PluginLogsSheet({
if (logs.isError) {
content = (
<Alert
size="sm"
variant="error"
title={t("settings.plugins.logs.errorTitle")}
description={errorMessage(logs.error)}
@@ -412,6 +413,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
if (pageState === "offline") {
return (
<Alert
size="md"
variant="warning"
title={t("settings.plugins.states.offlineTitle")}
description={t("settings.plugins.states.offlineDescription")}
@@ -419,13 +421,14 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
);
}
if (pageState === "unsupported") {
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
return <Alert size="md" variant="warning" title={t("settings.plugins.states.updateTitle")} />;
}
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
let catalogContent = <Alert size="sm" title={t("settings.plugins.states.loading")} />;
if (pageState === "error") {
catalogContent = (
<Alert
size="sm"
variant="error"
title={t("settings.plugins.states.errorTitle")}
description={errorMessage(plugins.error)}
@@ -469,6 +472,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
<View>
<SettingsSection title={t("settings.plugins.title")}>
<Alert
size="sm"
variant="warning"
title={t("settings.plugins.trustedTitle")}
description={t("settings.plugins.trustedDescription")}
@@ -508,10 +512,15 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
</Button>
</View>
) : (
<Alert variant="warning" title={t("settings.plugins.states.sourceUpdateTitle")} />
<Alert
size="sm"
variant="warning"
title={t("settings.plugins.states.sourceUpdateTitle")}
/>
)}
{feedback ? (
<Alert
size="sm"
variant={feedback.kind}
title={feedback.message}
testID="plugin-management-feedback"
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { SettingsSection } from "@/components/settings";
import { FormTextInput } from "@/components/ui/form-field";
import { parseTerminalScrollbackLines, useAppSettings } from "@/hooks/use-settings";
import { settingsStyles } from "@/styles/settings";
export function TerminalSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const [scrollbackValue, setScrollbackValue] = useState(String(settings.terminalScrollbackLines));
const handleChangeText = useCallback((value: string) => {
setScrollbackValue(value.replace(/[^\d]/g, ""));
}, []);
const commitScrollback = useCallback(() => {
const nextValue =
parseTerminalScrollbackLines(scrollbackValue) ?? settings.terminalScrollbackLines;
setScrollbackValue(String(nextValue));
if (nextValue !== settings.terminalScrollbackLines) {
void updateSettings({ terminalScrollbackLines: nextValue });
}
}, [scrollbackValue, settings.terminalScrollbackLines, updateSettings]);
useEffect(() => {
setScrollbackValue(String(settings.terminalScrollbackLines));
}, [settings.terminalScrollbackLines]);
return (
<SettingsSection title={t("settings.sections.terminal")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.terminalScrollback.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.terminalScrollback.description")}
</Text>
</View>
<FormTextInput
size="sm"
initialValue={scrollbackValue}
onChangeText={handleChangeText}
onBlur={commitScrollback}
onSubmitEditing={commitScrollback}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.scrollbackInput}
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
/>
</View>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
scrollbackInput: {
width: 112,
textAlign: "right",
},
});
+16
View File
@@ -159,6 +159,20 @@ const darkStatusColors = {
statusMerged: "#a890d5",
};
// Status tints — the fill of a status badge. The status color itself at low opacity, so a
// badge takes its state's hue from the same source as its text and never drifts from it.
// Dark surfaces swallow more of the tint, so the dark band runs a little stronger.
function statusTints(colors: typeof lightStatusColors, alphaHex: string) {
return {
statusSuccessTint: `${colors.statusSuccess}${alphaHex}`,
statusDangerTint: `${colors.statusDanger}${alphaHex}`,
statusWarningTint: `${colors.statusWarning}${alphaHex}`,
};
}
const lightStatusTints = statusTints(lightStatusColors, "1f"); // 12%
const darkStatusTints = statusTints(darkStatusColors, "29"); // 16%
// Status *dot* colors — the small filled discs on a sidebar row, and the glyphs that stand in
// for them. Same four hues and the same generation rule as the status colors above, but its
// own band, because a dot is doing a different job than a check icon or a host badge.
@@ -289,6 +303,7 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
...lightDiffColors,
...lightStatusColors,
...lightStatusTints,
...lightStatusDotColors,
terminal: {
@@ -420,6 +435,7 @@ export function buildDarkSemanticColors(tint: DarkThemeConfig) {
...darkDiffColors,
...darkStatusColors,
...darkStatusTints,
...darkStatusDotColors,
terminal: {
+13 -2
View File
@@ -76,6 +76,15 @@ describe("normalizeStoredHostProfile", () => {
expect(profile?.connections[0]).not.toHaveProperty("password");
});
it("moves a stored direct TCP password to the host profile", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_legacy",
connections: [{ type: "directTcp", endpoint: "localhost:6767", password: "old-secret" }],
});
expect(profile?.password).toBe("old-secret");
expect(profile?.connections[0]).not.toHaveProperty("password");
});
it("preserves legacy relay ids when TLS is absent", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_relay",
@@ -228,26 +237,28 @@ describe("upsertHostConnectionInProfiles", () => {
type: "directTcp",
endpoint: "example.test:6767",
useTls: false,
password: "old-secret",
};
const existing: HostProfile = {
...makeHost("srv_known"),
password: "old-secret",
connections: [existingConnection],
preferredConnectionId: existingConnection.id,
};
const replacement: HostConnection = {
...existingConnection,
useTls: true,
password: "new-secret",
};
const [profile] = upsertHostConnectionInProfiles({
profiles: [existing],
serverId: "srv_known",
connection: replacement,
password: "new-secret",
});
expect(profile.connections).toEqual([replacement]);
expect("password" in profile.connections[0]!).toBe(false);
expect(profile.password).toBe("new-secret");
expect(profile.preferredConnectionId).toBe(replacement.id);
});
});
+66 -26
View File
@@ -4,7 +4,7 @@ import {
} from "@getpaseo/protocol/daemon-endpoints";
import {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection,
type DirectTcpHostConnection as WireDirectTcpHostConnection,
} from "@getpaseo/protocol/host-connection-schema";
import {
DEFAULT_SSH_DAEMON_PORT,
@@ -18,7 +18,8 @@ import {
} from "@/hosts/appearance";
import { z } from "zod";
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
export { DirectTcpHostConnectionSchema };
export type DirectTcpHostConnection = Omit<WireDirectTcpHostConnection, "password">;
export interface DirectSocketHostConnection {
id: string;
@@ -59,6 +60,7 @@ export type HostLifecycle = Record<string, never>;
export interface HostProfile {
serverId: string;
password?: string;
label: string;
appearance: HostAppearance;
lifecycle: HostLifecycle;
@@ -125,11 +127,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
}
if (left.type === "directTcp" && right.type === "directTcp") {
return (
left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password
);
return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false);
}
if (left.type === "directSocket" && right.type === "directSocket") {
return left.path === right.path;
@@ -186,11 +184,40 @@ function upsertHostConnectionById(
return next;
}
function hasProfileChanged(input: {
matchingCount: number;
previous: HostProfile;
serverId: string;
password?: string;
createdAt: string;
label: string;
preferredConnectionId: string;
lifecycle: HostLifecycle;
connections: HostConnection[];
}): boolean {
const { previous, connections } = input;
return (
input.matchingCount > 1 ||
previous.serverId !== input.serverId ||
(input.password !== undefined && input.password !== previous.password) ||
input.createdAt !== previous.createdAt ||
input.label !== previous.label ||
input.preferredConnectionId !== previous.preferredConnectionId ||
!hostLifecycleEquals(previous.lifecycle, input.lifecycle) ||
connections.length !== previous.connections.length ||
connections.some((candidate, index) => {
const old = previous.connections[index];
return !old || !hostConnectionEquals(candidate, old);
})
);
}
export function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[];
serverId: string;
label?: string;
connection: HostConnection;
password?: string;
now?: string;
}): HostProfile[] {
const serverId = input.serverId.trim();
@@ -199,13 +226,17 @@ export function upsertHostConnectionInProfiles(input: {
}
const now = input.now ?? new Date().toISOString();
const password = input.password;
const normalizedConnection = input.connection;
const labelTrimmed = input.label?.trim() ?? "";
const derivedLabel = labelTrimmed || serverId;
const existing = input.profiles;
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
if (
daemon.serverId === serverId ||
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
daemon.connections.some((existingConnection) =>
hostConnectionEquals(existingConnection, normalizedConnection),
)
) {
matches.push(index);
}
@@ -215,11 +246,12 @@ export function upsertHostConnectionInProfiles(input: {
if (matchingIndexes.length === 0) {
const profile: HostProfile = {
serverId,
...(password ? { password } : {}),
label: derivedLabel,
appearance: defaultHostAppearance(),
lifecycle: defaultLifecycle(),
connections: [input.connection],
preferredConnectionId: input.connection.id,
connections: [normalizedConnection],
preferredConnectionId: normalizedConnection.id,
createdAt: now,
updatedAt: now,
};
@@ -230,7 +262,7 @@ export function upsertHostConnectionInProfiles(input: {
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
const nextConnections = upsertHostConnectionById(
matchedProfiles.flatMap((daemon) => daemon.connections),
input.connection,
normalizedConnection,
);
const nextLifecycle = prev.lifecycle;
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
@@ -238,23 +270,22 @@ export function upsertHostConnectionInProfiles(input: {
prev.preferredConnectionId &&
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
? prev.preferredConnectionId
: input.connection.id;
: normalizedConnection.id;
const nextCreatedAt = matchedProfiles.reduce(
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
prev.createdAt,
);
const changed =
matchingIndexes.length > 1 ||
prev.serverId !== serverId ||
nextCreatedAt !== prev.createdAt ||
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
nextConnections.length !== prev.connections.length ||
nextConnections.some((connection, index) => {
const previousConnection = prev.connections[index];
return !previousConnection || !hostConnectionEquals(connection, previousConnection);
});
const changed = hasProfileChanged({
matchingCount: matchingIndexes.length,
previous: prev,
serverId,
password,
createdAt: nextCreatedAt,
label: nextLabel,
preferredConnectionId: nextPreferredConnectionId,
lifecycle: nextLifecycle,
connections: nextConnections,
});
if (!changed) {
return existing;
@@ -262,6 +293,7 @@ export function upsertHostConnectionInProfiles(input: {
const nextProfile: HostProfile = {
...prev,
...(password ? { password } : {}),
serverId,
label: nextLabel,
lifecycle: nextLifecycle,
@@ -386,6 +418,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
]);
const StoredHostProfileSchema = z.strictObject({
serverId: z.string().trim().min(1),
password: z.string().optional(),
label: z.string().optional(),
appearance: HostAppearanceSchema.optional(),
lifecycle: z.strictObject({}).optional(),
@@ -401,13 +434,13 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
if (connection.type === "directTcp") {
try {
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
return DirectTcpHostConnectionSchema.parse({
const parsed = DirectTcpHostConnectionSchema.parse({
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: connection.useTls,
...(connection.password !== undefined ? { password: connection.password } : {}),
});
return { id: parsed.id, type: parsed.type, endpoint: parsed.endpoint, useTls: parsed.useTls };
} catch {
return null;
}
@@ -459,6 +492,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
}
const record = result.data;
const serverId = record.serverId;
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 once stored direct passwords have migrated.
const legacyPassword = record.connections.find(
(connection) => connection.type === "directTcp" && connection.password,
);
const password =
record.password ?? (legacyPassword?.type === "directTcp" ? legacyPassword.password : undefined);
const connections = record.connections
.map((connection) => normalizeStoredConnection(connection))
@@ -478,6 +517,7 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
return {
serverId,
...(password ? { password } : {}),
label,
appearance: record.appearance ?? defaultHostAppearance(),
lifecycle: defaultLifecycle(),
@@ -5,6 +5,8 @@ import {
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseRelayConnectionUri,
serializeRelayConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
@@ -22,6 +24,8 @@ export {
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseRelayConnectionUri,
serializeRelayConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
+4 -1
View File
@@ -492,7 +492,10 @@ export function resolveKnownHostRoute(input: {
export const SETTINGS_SECTION_SLUGS = [
"general",
"appearance",
"layout",
"sidebar",
"chat",
"terminal",
"browser",
"editor",
"shortcuts",
"integrations",
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
class FakeDaemonClient implements DaemonProbeClient {
@@ -169,16 +170,15 @@ describe("test-daemon-connection connectToDaemon", () => {
});
});
it("passes direct TCP connection passwords into the client config", async () => {
it("passes the host password into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
{ password: "shared-secret" },
probe.deps,
);
await result.client.close();
@@ -242,7 +242,7 @@ describe("test-daemon-connection connectToDaemon", () => {
it("surfaces auth rejection as an incorrect password", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(
new Error("Transport closed (code 4001)"),
new DaemonAuthenticationError("incorrect_password"),
"Transport closed (code 4001)",
);
@@ -252,13 +252,13 @@ describe("test-daemon-connection connectToDaemon", () => {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
},
undefined,
{ password: "wrong-secret" },
probe.deps,
),
).rejects.toMatchObject({
message: "Incorrect password",
authFailureReason: "incorrect_password",
});
});
@@ -272,9 +272,8 @@ describe("test-daemon-connection connectToDaemon", () => {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
{ password: "shared-secret" },
probe.deps,
),
).rejects.toMatchObject({
@@ -1,4 +1,9 @@
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
DaemonAuthenticationError,
DaemonClient,
getDaemonAuthFailureReason,
type DaemonAuthFailureReason,
} from "@getpaseo/client/internal/daemon-client";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
@@ -16,6 +21,7 @@ import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daem
export interface DaemonProbeClient {
readonly lastError: string | null;
readonly authFailureReason?: DaemonAuthFailureReason | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
@@ -84,41 +90,53 @@ function pickBestReason(reason: string | null, lastError: string | null): string
return "Unable to connect";
}
function isIncorrectPasswordFailure(input: {
config: DaemonClientConfig;
reason: string | null;
lastError: string | null;
}): boolean {
if (!input.config.password) {
return false;
}
const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase();
return (
details.includes("401") ||
details.includes("4001") ||
details.includes("unauthorized") ||
details.includes("code 1006")
);
}
export class DaemonConnectionTestError extends Error {
reason: string | null;
lastError: string | null;
authFailureReason: DaemonAuthFailureReason | null;
constructor(message: string, details: { reason: string | null; lastError: string | null }) {
constructor(
message: string,
details: {
reason: string | null;
lastError: string | null;
authFailureReason?: DaemonAuthFailureReason | null;
},
) {
super(message);
this.name = "DaemonConnectionTestError";
this.reason = details.reason;
this.lastError = details.lastError;
this.authFailureReason = details.authFailureReason ?? null;
}
}
export function getConnectionAuthFailureReason(error: unknown): DaemonAuthFailureReason | null {
if (error instanceof DaemonConnectionTestError) return error.authFailureReason;
return getDaemonAuthFailureReason(error);
}
function resolveConnectionCredentials(
connection: HostConnection,
options:
| { password?: string; localCredential?: DaemonClientConfig["localCredential"] }
| undefined,
): Pick<DaemonClientConfig, "password" | "localCredential"> {
const password = options?.password;
return {
...(password ? { password } : {}),
...(options?.localCredential ? { localCredential: options.localCredential } : {}),
};
}
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
options?: {
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
password?: string;
localCredential?: DaemonClientConfig["localCredential"];
},
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
@@ -136,6 +154,7 @@ export async function buildClientConfig(
appVersion: deps.resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...resolveConnectionCredentials(connection, options),
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}),
...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -167,7 +186,6 @@ export async function buildClientConfig(
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
...(connection.password ? { password: connection.password } : {}),
};
}
@@ -245,11 +263,13 @@ export function connectAndProbe(
error instanceof Error ? error.message : String(error),
);
const lastError = normalizeNonEmptyString(client.lastError);
const message = isIncorrectPasswordFailure({ config, reason, lastError })
? "Incorrect password"
const authFailureReason =
getDaemonAuthFailureReason(error) ?? client.authFailureReason ?? null;
const message = authFailureReason
? new DaemonAuthenticationError(authFailureReason).message
: pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError }));
reject(new DaemonConnectionTestError(message, { reason, lastError, authFailureReason }));
});
},
);
@@ -258,6 +278,8 @@ export function connectAndProbe(
interface ProbeOptions {
serverId?: string;
timeoutMs?: number;
password?: string;
localCredential?: DaemonClientConfig["localCredential"];
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
}
@@ -4,6 +4,7 @@ import {
collectAllPanes,
DEFAULT_PANE_ID,
findPaneById,
findPaneContainingTab,
useWorkspaceLayoutStore,
type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store";
@@ -109,6 +110,10 @@ export function openWorkspaceTargetAtLocation(
if (shouldOpenBeside && !targetAlreadyExists) {
const paneId = store.ensureSidePane(input.workspaceKey);
placement = paneId ? { mode: "prefer", paneId } : undefined;
} else if (!shouldOpenBeside && layout && input.parentTabId) {
// A target opened from a tab belongs in that tab's pane, whichever pane has focus.
const parentPane = findPaneContainingTab(layout.root, input.parentTabId);
placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined;
}
return store.openTab({
workspaceKey: input.workspaceKey,
@@ -136,6 +136,24 @@ describe("resolveWorkspaceFilePaths", () => {
});
});
it("resolves files under a drive-root workspace without doubling the separator", () => {
expect(resolveWorkspaceFilePaths({ path: "C:/src/app.ts", workspaceRoot: "C:\\" })).toEqual({
absolutePath: "C:/src/app.ts",
relativePath: "src/app.ts",
});
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "C:\\" })).toEqual({
absolutePath: "C:/src/app.ts",
relativePath: "src/app.ts",
});
});
it("resolves files under the POSIX root workspace", () => {
expect(resolveWorkspaceFilePaths({ path: "/src/app.ts", workspaceRoot: "/" })).toEqual({
absolutePath: "/src/app.ts",
relativePath: "src/app.ts",
});
});
it("returns null for home-relative paths that cannot be anchored", () => {
expect(
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
+13 -2
View File
@@ -129,6 +129,17 @@ function startsWithPath(value: string, prefix: string): boolean {
: value.startsWith(prefix);
}
function pathWithinRoot(root: string, relativePath: string): string {
return root.endsWith("/") ? `${root}${relativePath}` : `${root}/${relativePath}`;
}
/** Whether an absolute path names the workspace directory itself. */
export function isWorkspaceRootPath(input: { path: string; workspaceRoot: string }): boolean {
const path = normalizeAbsolutePath(input.path.trim().replace(/\\/g, "/"));
const root = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
return path !== null && root !== null && pathsEqual(path, root);
}
export interface ResolvedWorkspaceFilePaths {
/** Absolute path on the host, suitable for opening in an editor / file manager. */
absolutePath: string;
@@ -159,7 +170,7 @@ export function resolveWorkspaceFilePaths(input: {
if (pathsEqual(normalizedFile, workspaceRoot)) {
return null;
}
const prefix = `${workspaceRoot}/`;
const prefix = pathWithinRoot(workspaceRoot, "");
const relativePath = startsWithPath(normalizedFile, prefix)
? normalizedFile.slice(prefix.length)
: null;
@@ -174,5 +185,5 @@ export function resolveWorkspaceFilePaths(input: {
if (!relativePath) {
return null;
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath };
}
@@ -7,6 +7,10 @@ const testTheme = {
statusDanger: "#b91c1c",
statusWarning: "#d97706",
statusMerged: "#7c3aed",
// Same rule as the real theme: the status color at 12%.
statusSuccessTint: "#15803d1f",
statusDangerTint: "#b91c1c1f",
statusWarningTint: "#d977061f",
// The light band's values, so a test can name the colour it expects.
statusDotSuccess: "#299f51",
statusDotDanger: "#f12e2f",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.9.2",
"version": "0.10.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"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.2",
"@getpaseo/protocol": "0.10.2",
"@getpaseo/server": "0.10.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -136,8 +136,8 @@ test("managed two-home restart retains its supervisor and never routes ordinary
const beforeA = await f.liveStatus(a);
const beforeB = await f.liveStatus(b, poisoned);
if (process.platform !== "win32") {
for (const home of [a, b, path.join(f.root, ".paseo")])
expect((await stat(home)).mode & 0o777).toBe(0o700);
for (const home of [a, b]) expect((await stat(home)).mode & 0o777).toBe(0o700);
expect(existsSync(path.join(f.root, ".paseo"))).toBe(false);
}
const repoB = path.join(f.root, "project-b");
@@ -197,7 +197,7 @@ test("removed flags and ambiguous targets fail before side effects; observation
}
}, 30_000);
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
test("local status uses the credential and reports the server id of a password-protected daemon", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
@@ -209,7 +209,7 @@ test("status reports the server id of a password-protected daemon to a caller wi
await f.ok(["start", "--home", home, "--timeout", "30"]);
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
connectedDaemon: "auth_required",
connectedDaemon: "reachable",
serverId: authenticated.serverId,
});
} finally {
+7 -1
View File
@@ -12,6 +12,8 @@ import { connectToDaemon } from "../../utils/client.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { serializeRelayConnectionUri } from "@getpaseo/protocol/daemon-endpoints";
interface PairOptions {
daemonTarget: DaemonTarget;
@@ -195,10 +197,13 @@ function outputPairingResult(
return;
}
const offer = parseConnectionOfferFromUrl(pairing.url);
const connectionUri = offer ? serializeRelayConnectionUri({ offer }) : null;
if (options.json) {
output.writeStdout(
`${JSON.stringify(
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr },
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr, connectionUri },
null,
2,
)}\n`,
@@ -210,6 +215,7 @@ function outputPairingResult(
formatPairingInstructions({
url: pairing.url,
qr: pairing.qr,
connectionUri,
columns: output.columns,
}),
);
+13
View File
@@ -30,4 +30,17 @@ describe("formatPairingInstructions", () => {
expect(output).toContain("terminal width could not be detected");
expect(output.split("\n")).toContain(URL);
});
it("redacts a password in a printed connection URI", () => {
const output = formatPairingInstructions({
qr: null,
url: URL,
connectionUri: "relay://relay.paseo.sh:443/srv_test?key=public&password=private-value",
});
expect(output).toContain(
"relay://relay.paseo.sh:443/srv_test?key=public&password=%5Bredacted%5D",
);
expect(output).not.toContain("private-value");
});
});
+20 -2
View File
@@ -3,6 +3,7 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
interface PairingInstructions {
url: string;
qr: string | null;
connectionUri?: string | null;
columns?: number;
}
@@ -32,6 +33,23 @@ function formatQr(qr: string | null, columns: number | undefined): string {
return qr;
}
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
function redactConnectionUri(uri: string): string {
try {
const parsed = new URL(uri);
if (parsed.searchParams.has("password")) {
parsed.searchParams.set("password", "[redacted]");
}
return parsed.toString();
} catch {
return "Invalid connection URI";
}
}
export function formatPairingInstructions({
url,
qr,
columns,
connectionUri,
}: PairingInstructions): string {
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}${connectionUri ? `\n\nConnection URI:\n${redactConnectionUri(connectionUri)}` : ""}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
}

Some files were not shown because too many files have changed in this diff Show More