Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4aa2f2817d chore(release): cut 0.11.0-beta.1 2026-10-01 10:48:28 +02:00
Mohamed Boudra bdb8e13221 docs(changelog): add 0.11.0-beta.1 release notes 2026-10-01 10:41:23 +02:00
paseo-bot[bot] 06213e49bf Complete every Tintinweb subagent in a grouped completion (#5826)
Tintinweb batches completions that land close together into one
subagent-notification, with the first agent at the top level and the
rest in details.others. The adapter only read the top level, so every
other agent in the group stayed running in the Subagents track, live
and after a reload.

Closes #5822

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 10:11:05 +02:00
paseo-bot[bot]andGabriel Giordano d30e99c858 Log why a resumed Claude agent has no history (#5818)
When a resumed Claude agent's transcript was missing, history loading
returned without a log line, and when reading or parsing the transcript
threw, the error was swallowed. Either way the agent opened with an
empty timeline and daemon.log said nothing. A missing transcript is now
logged at info with the session id, cwd and the path that was checked,
and a read or parse failure is logged as a warning with the error.

Closes #5438

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
2026-10-01 06:56:46 +02:00
Mohamed Boudra e939dd4c7a Streamline sidebar usage and stacked sheet interactions (#5805)
* feat(app): streamline sidebar usage and stacked sheets

* fix(app): restore compact sidebar navigation clicks

The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript.

* fix(app): cap menu sheets at eighty percent height

Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
2026-10-01 04:55:17 +01:00
paseo-bot[bot] 15f1431416 Let bundlers resolve the published @getpaseo/relay package (#5815)
The relay exports map sent the import and default conditions to src/*.ts,
which the published package does not include, so any bundler resolving
@getpaseo/client failed with 'Could not resolve "@getpaseo/relay/e2ee"'.
Every condition now resolves to dist, like @getpaseo/protocol and
@getpaseo/client. The client build now builds relay first, and the Metro
workaround for compiling relay source is removed.

Fixes #4730

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 05:01:45 +02:00
paseo-bot[bot]and3ae3ae b701cb2dcd Show failed Codex image generations in the chat (#5810)
When a Codex image request fails, Codex completes the imageGeneration
item with status "failed", an empty result, and an optional failure
record. The image mapper only knew how to render a successful image, so
it returned nothing and the item vanished from the live timeline and
from reopened history. Failed items now map to a failed image_generation
tool call carrying the failure details.

Closes #5619

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-10-01 03:57:56 +02:00
paseo-bot[bot] 4893629ff5 Show the thinking level an agent runs on in its composer (#5804)
The composer picked the thinking level from what was chosen at launch and
fell back to the model's catalog default when nothing was chosen. A Pi agent
started without a thinking level runs on Pi's own configured default, so the
composer showed Medium while the agent ran on High. The composer now prefers
the level the running agent reports when it is one of the model's options,
the same way it already prefers the running model.

Fixes #5800

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 02:55:22 +02:00
paseo-bot[bot]andYuval Marcus e0a0cf990d Ask OpenCode before running a Paseo tool (#5801)
OpenCode checks permissions for its own tools and wraps MCP tools in an
ask, but leaves plugin tools to call ctx.ask themselves. The bridge plugin
never did, so an "ask" rule for paseo_* tools never produced a prompt and
the tool ran. The bridge now asks with the tool's catalog name before
dispatching, the same shape OpenCode uses for MCP tools. With no paseo_*
rule OpenCode's default allow applies, so nothing new prompts.

Fixes #5324

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Yuval Marcus <ymarcus93@gmail.com>
2026-10-01 01:15:34 +02:00
Mohamed Boudra 7b0cace991 Restore Antigravity with Full access and session warnings (#5795)
* Revert "Remove the unreleased Antigravity provider plugin (#5781)"

This reverts commit 9d9d9af993.

* fix(providers): commit plugin notices emitted during session opening

* feat(providers): restore Antigravity with full access and session warnings

* fix(providers): warn once when opening Antigravity sessions

Driver respawn retains the live transcript and unchanged permission posture. Emit the warning only when opening a session, including resume after a daemon restart.
2026-09-30 23:18:05 +01:00
paseo-bot[bot] 20a887f4cc Show an agent's own model in the composer when the catalog lacks it (#5796)
The composer fell back to the catalog's default model whenever the agent's
model was missing from the loaded model list, so a Codex subagent started
on a model the catalog did not list yet was labeled as the default model,
with that model's thinking options.

Fixes #5791

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 00:00:16 +02:00
paseo-bot[bot]andodoo-mvds a7c9c05bef Start new workspaces off origin/main at the remote tip (#5788)
* fix(server): start new worktrees at the remote tip of an origin/* base

Branching off a remote-tracking base such as origin/main used whatever
the repository had cached. Nothing on the branch-off path fetched, and the
background fetch only runs while a workspace of that repository is
observed, so a new workspace could start hours or weeks behind the remote.

Fetch just the base branch from its remote before git worktree add. The
refspec is passed on the command line so remote.<name>.fetch is untouched,
and a failed fetch falls back to the cached ref so creation still works
offline.

Co-authored-by: odoo-mvds <mv@droggol.be>

* fix(server): fall back to the cached base before the create request times out

A remote that accepts the connection and never answers held the base-ref
fetch for its 120s cap, past the 60s client request timeout, so
paseo worktree create failed and the worktree appeared later. Cap the
fetch at 15s so creation falls back to the cached ref in time.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: odoo-mvds <mv@droggol.be>
2026-09-30 22:59:33 +02:00
Mohamed Boudra 1f5f5d384e Sidebar usage shows default percents with logos, and remembers the picked host (#5786)
* fix(app): show default usage windows when no windows are pinned

* fix(app): show only percentages in sidebar usage summaries

* fix(client): restore usage source logos for released hosts

* fix(app): restore the labeled footer line below sidebar items

* fix(app): use History's host filter to pick the usage and plugin host

The Usage screen, the compact usage sheet and plugin screens used a separate
HostSwitcher trigger. They now use HostFilter without "All hosts", as the
label manager does, and hide it with one host, as History does.

* fix(app): save the usage host pick and show it in the sidebar summary

Picking a host on the Usage screen or usage sheet was view state: it reset when
the view closed, and the sidebar summary never read it. The pick is now a
device-local usage preference. The summary and the screen show the picked host
while it is connected, else the active workspace's host, else the first host.

The usage fixture answers by request, since the summary and the screen each
load reports and only a card's Refresh forces one.

* test(app): expect plugin footer rows above the footer bottom line

Add project moved to the bottom line with the icons, so footer rows sit above
it, spanning Add project to Settings.

* test(app): expect the saved usage host in stored settings

* test(app): let usage settings specs share reports with the sidebar summary

The sidebar summary loads reports on app start, so the settings specs answer
by request instead of by order and read percents from the settings card.
2026-09-30 21:09:07 +01:00
Mohamed Boudra 695ecb2812 fix(muse): make package installs and project rules work by default (#5775)
* fix(muse): make launch defaults usable and configure each agent

Disable sandboxing and trust workspaces by default so package installs and project rules work. Replace the host settings UI with validated per-agent providerOptions, preserving posture across session resume.

* docs(muse): describe shared provider options configuration
2026-09-30 20:09:10 +01:00
paseo-bot[bot] 7a64eef491 Show the command on Codex terminal rows (#5774)
* fix(codex): label terminal writes with the command Codex started

Codex app-server reports the PTY process id on the commandExecution item,
and no longer sends the legacy exec_command_end event that carried it as
text. Record the process-to-command label from the item so terminal
writes show which command they went to.

* ci: rerun after website e2e server start timeout

* fix(codex): unwrap the shell from terminal labels like shell rows

Terminal labels now use the same command normalization as the shell row,
so a Linux wrapper such as /usr/bin/zsh -lc 'python3 -i' labels the
terminal as python3 -i.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 20:50:24 +02:00
Mohamed Boudra 9d9d9af993 Remove the unreleased Antigravity provider plugin (#5781)
* refactor(providers): remove the unreleased Antigravity plugin

Remove the provider at the owner’s request because headless command approvals are unsupported. Preserve Muse, shared plugin infrastructure, and earlier editor and terminal integrations.

* test(plugins): keep builtin startup coverage generic
2026-09-30 19:39:09 +01:00
Mohamed Boudra 29c198f955 fix(providers): apply configured options to every agent provider (#5780)
* fix(providers): apply configured options at session startup

Treat provider options as one opaque record, with provider-owned validation and per-agent overrides over configured defaults. Keep agent overrides separate from defaults so configuration changes apply on the next launch or resume.

* test(providers): verify provider-owned validation through Hub

* fix(providers): preserve the complete wrapped session interface
2026-09-30 19:37:14 +01:00
Mohamed Boudra 318a84a570 Keep native dialogs and sheets usable when stacked (#5772)
* fix(app): stack adaptive sheets across native presentations

Root-owned dialogs could not present over a sibling native dialog on iOS. Use the existing app-wide sheet stack for both native presentation forms, and release the underlying keyboard when a new sheet opens.

* test(app): verify pairing sheet autofocus

* docs(app): clarify stacked sheet journey prerequisites
2026-09-30 18:11:38 +01:00
Mohamed Boudra 364e6456e0 Add confirmation for new hosts (#5753)
* Add confirmation for new hosts

Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.

The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.

Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.

* Only ask for new hosts or changed connections

A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.

* Move host confirmation into the host runtime

The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.

A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.

* Stop QR scanning after an attempt ends

The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
2026-09-30 18:11:23 +01:00
paseo-bot[bot] d070db15b3 fix(acp): report the agent's error when session setup is rejected (#5765)
ACP agents reject mode, model, thinking, and feature writes as plain
JSON-RPC error objects. Those calls skipped the request error
normalization, so schedule runs and metadata generation recorded
"[object Object]" instead of the agent's message.

Refs #4666

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 18:19:05 +02:00
paseo-bot[bot]andArietidsZ 4417d7c47a fix(pi): give Pi's built-in MCP the agent's MCP servers (#5762)
Pi 0.99 ships MCP as a built-in extension. Paseo only recognized
pi-mcp-adapter's /mcp command, so a Pi agent without the adapter started
without Paseo's MCP servers. Pi 0.99 also rejects the adapter's
--mcp-config flag, so the servers are now registered with
pi.registerMcpServer from Paseo's per-session extension. Sessions that
load pi-mcp-adapter keep the --mcp-config path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: ArietidsZ <69024461+ArietidsZ@users.noreply.github.com>
2026-09-30 16:08:18 +02:00
Mohamed Boudraandmillerben95 8332f4d1ff feat(muse): add Muse Code as a built-in provider plugin (#5719)
* test(muse): record real host protocol fixtures

* feat(muse): add built-in MSP provider

Co-authored-by: millerben95 <millerben95@users.noreply.github.com>

* fix(muse): expose provider settings in the app

The daemon registered host settings without a client contribution, leaving sandbox, network, and workspace trust unreachable. Share the definition with a settings screen and expose it through Command Center even when installed plugins are disabled.

* docs(muse): publish setup and provider support

* test: correct runtime fixtures and restart readiness

* fix(muse): hide reminder housekeeping from the timeline

* fix(muse): use the official Muse Code mark

Source: https://muse.ai/images/favicon/app-squiggle.svg

Preserve the official silhouette path verbatim. Remove its gradient definition and use currentColor, scaling the original 100-unit coordinates into the 24-unit viewBox. Black silhouette renders match pixel-for-pixel.

* fix(muse): adapt to current plugin APIs

Use the screen API introduced with sidebar plugin items. Usage reports now come from source account discovery; remove the retired agent usage-reference request and headline marker, and exercise discovery through the existing provider fixtures.

---------

Co-authored-by: millerben95 <millerben95@users.noreply.github.com>
2026-09-30 14:56:41 +01:00
255 changed files with 13930 additions and 1937 deletions
+72
View File
@@ -1,5 +1,77 @@
# Changelog
## 0.11.0-beta.1 - 2026-10-01
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
- Added provider option defaults in `config.json` under `agents.providers.<id>.options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
### Changed
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340))
### Fixed
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684))
- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686))
- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758))
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
## 0.10.2 - 2026-09-30
### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
## Repository map
+4 -2
View File
@@ -29,7 +29,7 @@
</a>
</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -42,7 +42,7 @@
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
@@ -68,6 +68,8 @@ You need at least one agent CLI installed and configured with your credentials:
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity)
- [Muse Code](https://paseo.sh/docs/muse-code)
### Desktop app (recommended)
+38 -9
View File
@@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds:
The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
## Provider options
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
`Record<string, unknown>` values: the provider owns validation and application.
Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error.
Providers without option handling ignore the record.
The daemon deep-merges the agent's options over the provider defaults at each
session launch or resume. Plain objects merge recursively; arrays, scalars, and
`null` replace the base value. Only the agent's own options are persisted, so edits
to provider defaults apply on the next launch or resume. A profile inherits its
base provider's options unless it sets its own options record.
```json
{
"agents": {
"providers": {
"codex": {
"options": { "sandbox_mode": "read-only", "approval_policy": "never" }
}
}
}
}
```
See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for
per-agent examples and provider-specific keys.
---
## Table of Contents
@@ -391,7 +420,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"options": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"rpcTimeoutMs": 60000,
"smolModel": "openai/gpt-5-mini",
@@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
@@ -416,7 +445,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"options": {
"sessionDir": "~/.my-pi-fork/sessions",
"rpcTimeoutMs": 60000
}
@@ -426,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
---
@@ -484,7 +513,7 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`:
```json
{
@@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"options": {
"supportsMcpServers": false
}
}
@@ -505,7 +534,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
ACP agents execute filesystem operations in their own environment by default,
while terminal operations run through Paseo on the host. To customize which
operations Paseo handles, configure client capabilities in provider params:
operations Paseo handles, configure client capabilities in provider options:
```json
{
@@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params:
"extends": "acp",
"label": "Container Agent",
"command": ["container-agent", "acp"],
"params": {
"options": {
"clientCapabilities": {
"fs": {
"readTextFile": false,
@@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields:
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
+16
View File
@@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
includes native centered dialogs: separate React Native Modals cannot present a root-owned
sibling dialog over an existing iOS presenter.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
and restoration then silently do nothing. Callers may use callback refs on the wrapper, which
forwards commands to the current Gorhom handle.
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
## Gotcha 7 — A sheet cannot read context from its call site
React cannot copy contexts reflectively, so the only way across the teleport in
+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 configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries 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
@@ -191,6 +191,8 @@ its own.
[floating-panels.md](floating-panels.md).
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
+23 -8
View File
@@ -88,12 +88,23 @@ never deletes it. The global `pluginsEnabled` switch remains available.
## Built-in plugins
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
`server/`, and optional client entry and icon. Add the plugin ID to `builtinPlugins` in
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
Built-ins run in process and remain active independently of `pluginsEnabled`. They are
absent from the installed plugin list and source configuration; their client bundles
appear in the plugin catalog. Editing one in development requires a daemon restart.
Directory, Git, and npm installs cannot use a built-in ID.
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
availability and a diagnostic after the daemon resolves the executable. Configure command,
environment, or enablement overrides under `agents.providers.muse`. Omit `extends` to keep
the bundled integration; an entry with `extends` shadows it with a custom provider. See
[provider contributions](#contribute-a-provider) for the contract and
[Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations.
## Install a Git source
@@ -420,7 +431,11 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `providerOptions` config object.
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
@@ -463,7 +478,7 @@ one section's order from built-ins, plugin groups, and the section's preference
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
are rows between Add project and the footer's icon row. The icon row (Hosts,
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
button.
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
+17 -45
View File
@@ -6,7 +6,7 @@ server integrations.
## Plugin providers
Keep a bundled provider in `plugins/<id>/` and register it through
`@getpaseo/plugin/server/provider`. Antigravity follows this pattern. Built-in loading and SDK
`@getpaseo/plugin/server/provider`. Antigravity and Muse Code follow this pattern. Built-in loading and SDK
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
@@ -15,52 +15,22 @@ executable resolution and applies `agents.providers.<provider-id>.command` and `
connecting. Register the provider's icon with the plugin rather than adding it to the app's
provider icon map. You do not need a core manifest entry or provider factory.
For Antigravity installation, modes, and limitations, see
[Supported providers](../public-docs/supported-providers.md#antigravity).
| Provider | Transport | Setup and limitations |
| ----------- | ------------------------------------------ | ---------------------------------------------------------------- |
| Antigravity | Installed `agy` CLI | [Antigravity](../public-docs/supported-providers.md#antigravity) |
| Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) |
## Provider-native session options
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
The provider owns validation and application of the opaque record in
`AgentSessionConfig.providerOptions`. The registry supplies the effective options
at session startup. See [provider configuration](custom-providers.md#provider-options)
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
This Paseo version accepts these keys:
- **Codex:** `approval_policy`, `sandbox_mode`,
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
application policy, not an OS sandbox.
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
## Core adapter patterns
@@ -85,7 +55,7 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -101,7 +71,9 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P
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.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path` `builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence.
The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36415,7 +36415,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37481,12 +37481,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/server": "0.11.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37733,10 +37733,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/relay": "0.11.0-beta.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37747,7 +37747,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37991,7 +37991,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38887,7 +38887,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38916,14 +38916,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38933,8 +38933,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38956,7 +38956,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38983,7 +38983,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39201,16 +39201,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/highlight": "0.10.0",
"@getpaseo/plugin": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/highlight": "0.11.0-beta.1",
"@getpaseo/plugin": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/relay": "0.11.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40131,7 +40131,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41736,7 +41736,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.0.0",
"version": "0.11.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1242,6 +1242,7 @@ test.describe("Agent message submission", () => {
await gate.waitForHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
await expectAgentReadyToInterrupt(page);
gate.releaseHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
} finally {
@@ -84,6 +84,18 @@ async function openModelSelector(page: Page) {
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
}
async function dismissStackedModelPickerWithBackdrop(page: Page) {
await page
.getByTestId("agent-controls-settings-list")
.getByRole("button", { name: /Select model/ })
.click();
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
@@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await dismissStackedModelPickerWithBackdrop(page);
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
@@ -0,0 +1,51 @@
import { test, expect } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { expectAppRoute } from "../support/helpers/route-assertions";
import {
buildPairingLink,
cancelHostConfirmation,
connectToConfirmedHost,
expectHostConfirmationFor,
expectHostInHostPicker,
hostConfirmation,
openPairingLink,
saveLinkedHostBeforeLoad,
type LinkedHost,
} from "../support/helpers/host-confirmation";
// An unreachable relay: the link saves the host without connecting first.
const linkedHost: LinkedHost = {
serverId: "srv_link_confirmation",
relayEndpoint: "127.0.0.1:59998",
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
};
test("a pairing link for a new host asks before saving it", async ({ page }) => {
const link = buildPairingLink(linkedHost);
await test.step("Cancel saves nothing and stays on the page", async () => {
await openPairingLink(page, link);
await expectHostConfirmationFor(page, linkedHost);
await cancelHostConfirmation(page);
await expectAppRoute(page, "/settings/general");
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
});
await test.step("Connect saves the host and opens a project", async () => {
await openPairingLink(page, link);
await expectHostConfirmationFor(page, linkedHost);
await connectToConfirmedHost(page);
await expectAppRoute(page, "/open-project");
await openSettings(page);
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
});
});
test("a pairing link for a saved host connects without asking", async ({ page }) => {
await saveLinkedHostBeforeLoad(page, linkedHost);
await openPairingLink(page, buildPairingLink(linkedHost));
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
await expect(hostConfirmation(page)).toHaveCount(0);
});
@@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => {
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await flow.revealMobileEntryPoint();
await flow.openGlobally();
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
@@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await test.step("the mobile sidebar exposes import in its footer", async () => {
await flow.revealMobileEntryPoint();
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
await test.step("the new workspace screen offers import at the top", async () => {
await flow.revealNewWorkspaceEntryPoint();
await capture(page, testInfo, "01-mobile-new-workspace-import.png");
});
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
await flow.openGlobally();
await flow.openFromNewWorkspace();
await flow.expectScope("Sessions on", false);
await flow.expectRows({
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
@@ -40,22 +40,21 @@ async function qaScreenshot(page: Page, name: string, area?: Locator) {
else await page.screenshot({ path: file });
}
/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */
/** Footer rows sit above the fixed bottom line, spanning it from Add project to Settings. */
async function expectFooterRow(page: Page, row: Locator) {
// Polled: on compact the drawer is still sliding in when the row first shows.
await expect
.poll(async () => {
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
const hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!;
const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!;
const box = (await row.boundingBox())!;
return {
belowAddProject: box.y >= addProject.y + addProject.height,
aboveIconRow: box.y + box.height <= hosts.y,
aboveBottomLine: box.y + box.height <= addProject.y,
alignedLeft: Math.abs(box.x - addProject.x) < 1,
fullWidth: Math.abs(box.width - addProject.width) < 1,
alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1,
};
})
.toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true });
.toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true });
}
function botRow(page: Page, rowId: string): Locator {
@@ -188,7 +187,7 @@ test.describe("Plugin sidebar items", () => {
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
});
await test.step("the footer row renders as a row between Add project and the icon row", async () => {
await test.step("the footer row renders as a row above the bottom line", async () => {
await expect(sync).toHaveAccessibleName("Sync");
await expectFooterRow(page, sync);
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
@@ -1,8 +1,18 @@
import { expect, test } from "../support/fixtures";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
} from "../support/helpers/usage-sidebar-item";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
import {
expectFooterIconRow,
expectFooterSeparator,
hoverFooterAddProject,
footerScreenshot,
expectFooterItemHidden,
expectFooterOrder,
expectFooterSettingsKeys,
@@ -21,6 +31,50 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]);
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
await footerScreenshot(page, "footer-desktop-with-rows");
await hoverFooterAddProject(page);
await footerScreenshot(page, "footer-desktop-add-project-tooltip");
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, ["usage"]);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectFooterSeparator(page, false);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-desktop-without-rows");
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectFooterSeparator(page, false);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-without-rows");
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
await expect(page.getByTestId("sidebar-add-project-tooltip")).toHaveCount(0);
await footerScreenshot(page, "footer-compact-add-project-hover");
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.setViewportSize({ width: 1440, height: 900 });
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-with-rows");
});
test.describe("Sidebar items in Appearance settings", () => {
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
await gotoAppShell(page);
@@ -147,6 +201,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
await leaveSettings(page);
await expectFooterOrder(page, ["usage", syncKey]);
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
});
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
@@ -160,6 +215,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible();
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
});
await test.step("the rows keep that shape across a reload", async () => {
@@ -169,6 +225,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
).toBeVisible({ timeout: 30_000 });
await expectFooterItemHidden(page, "usage");
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
});
+89 -25
View File
@@ -1,10 +1,22 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
installUsageReportsFixture,
type UsageListRequest,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import { expectPinnedUsage, usageItem } from "../support/helpers/usage-sidebar-item";
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
return usage.listRequests().filter((request) => request.forceRefresh);
}
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
@@ -20,15 +32,25 @@ async function qaScreenshot(page: Page, name: string) {
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
/** The host selector names the host shown, even when it is the only one. */
async function shownHostLabel(page: Page): Promise<string> {
const selector = page.locator('[data-testid="usage-host-switcher"]:visible');
await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 });
return (await selector.innerText()).trim();
function hostFilter(page: Page) {
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
}
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
return {
id: `${sourceId}:a`,
account: {},
fetchedAt: twoHoursAgo(),
sourceId,
sourceLabel: `${sourceId} plan`,
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] },
};
}
test.describe("usage screen", () => {
test("opens from the sidebar on the host's reports, naming the one host", async ({ page }) => {
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
@@ -64,10 +86,7 @@ test.describe("usage screen", () => {
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
const hostLabel = await shownHostLabel(page);
await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName(
`Usage host: ${hostLabel}`,
);
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "phase7-usage-screen-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
@@ -103,13 +122,13 @@ test.describe("usage screen", () => {
windows: [{ id: "weekly", label: "Weekly", usedPct }],
},
});
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
[alpha(31, twoHoursAgo()), beta],
() => [
alpha(58, new Date().toISOString()),
{ ...beta, fetchedAt: new Date().toISOString() },
],
(request) =>
request.forceRefresh
? [alpha(58, new Date().toISOString())]
: [alpha(31, twoHoursAgo()), beta],
],
});
@@ -120,11 +139,9 @@ test.describe("usage screen", () => {
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: undefined },
{ forceRefresh: true, reportIds: ["alpha:a"] },
]);
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
@@ -169,13 +186,60 @@ test.describe("usage screen", () => {
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
const hostLabel = await shownHostLabel(page);
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, {
exact: true,
}),
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
expect(usage.listRequests()).toHaveLength(0);
});
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
page,
}) => {
test.setTimeout(420_000);
const primaryServerId = getServerId();
const secondary = await startIsolatedHostDaemon(
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
);
try {
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
await installUsageReportsFixture(page, {
port: secondary.port,
lists: [[weeklyReport("beta", 12)]],
});
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondary.serverId,
label: "Secondary box",
port: secondary.port,
});
// Nothing picked and no workspace open: the first host.
await expectPinnedUsage(page, ["31% Weekly"]);
await usageItem(page).click();
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-screen-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
await addConnectedHostAndReload(page, {
serverId: secondary.serverId,
label: "Secondary box",
port: secondary.port,
});
await expectPinnedUsage(page, ["12% Weekly"]);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
}
});
});
+18 -18
View File
@@ -3,10 +3,18 @@ import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
installUsageReportsFixture,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
function forcedRefreshCount(usage: UsageReportsFixture): number {
return usage.listRequests().filter((request) => request.forceRefresh).length;
}
function report(input: {
sourceId: string;
sourceLabel: string;
@@ -69,7 +77,6 @@ test.describe("usage settings", () => {
await gotoAppShell(page);
await openSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openSettingsHostSection(page, serverId, "usage");
await usage.waitForListRequests(1);
@@ -83,7 +90,7 @@ test.describe("usage settings", () => {
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
await card.getByTestId("usage-display-remaining").click();
await showUsageAs(page, "remaining");
await expect(card.getByText("30% left")).toBeVisible();
await expect(card.getByText("93% left")).toBeVisible();
});
@@ -92,20 +99,14 @@ test.describe("usage settings", () => {
test.setTimeout(120_000);
const serverId = getServerId();
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
// The sidebar summary and the section each load reports; only Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
[
(request) => [
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(23) },
}),
],
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(64) },
report: { windows: windows(request.forceRefresh ? 64 : 23) },
}),
],
],
@@ -114,13 +115,12 @@ test.describe("usage settings", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
const card = page.getByTestId("usage-card");
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usage.waitForListRequests(2);
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
await expect(page.getByText("64%")).toBeVisible();
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64%")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
@@ -4,21 +4,28 @@ import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
import {
leaveSettings,
openSidebarNavSettings,
setFooterItemVisible,
} from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectNoPinnedUsage,
expectOnUsageScreen,
expectPinnedUsage,
leaveUsageScreen,
openCompactSidebar,
pinRow,
openUsageOptions,
showUsageAs,
togglePin,
usageItem,
usageSheet,
installTallUsageSource,
} from "../support/helpers/usage-sidebar-item";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -33,7 +40,7 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k
await page.waitForTimeout(600);
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `phase4-${name}.png`);
const file = path.join(directory, `${name}.png`);
if (area.kind === "element") {
await area.locator.screenshot({ path: file });
return;
@@ -42,17 +49,38 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k
await page.screenshot({ path: file, clip });
}
/** The sidebar footer, from Add project down to the icon row, with some margin. */
/** Includes summary rows above the fixed icon row. */
async function footerClip(page: Page) {
const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!;
const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!;
const margin = 16;
const x = Math.max(0, top.x - margin);
const y = Math.max(0, top.y - margin);
return { x, y, width: 300, height: bottom.y + bottom.height + margin - y };
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
test.describe("Usage item", () => {
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
page,
}) => {
test.setTimeout(120_000);
const source = await installTallUsageSource();
try {
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await usageItem(page).click();
const content = page.getByTestId("sidebar-usage-sheet-content");
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
await waitForSettledPosition(content);
const bounds = (await content.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
} finally {
await source.cleanup();
}
});
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
page,
}) => {
@@ -63,26 +91,42 @@ test.describe("Usage item", () => {
await gotoAppShell(page);
const screen = page.getByTestId(`usage-host-${serverId}`);
await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => {
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
await expectNoPinnedUsage(page);
await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" });
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
await openSidebarNavSettings(page);
await qaScreenshot(page, "desktop-settings-sidebar-footer");
await page.setViewportSize(COMPACT);
// Phones start with the Usage item off; turning it on stores the choice.
await expect(
page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"),
).toHaveAttribute("aria-checked", "false");
await setFooterItemVisible(page, "usage", true);
await qaScreenshot(page, "compact-settings-sidebar-footer");
await gotoAppShell(page);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "compact-footer-defaults");
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await usageItem(page).click();
await expectOnUsageScreen(page);
});
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
// One host: the selector still names it.
await expect(
page.locator('[data-testid="usage-host-switcher"]:visible'),
).toHaveAccessibleName(/^Usage host: .+/);
// One host: no host filter, as on History.
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
0,
);
await togglePin(screen, "Claude", "Session");
await expectPinnedUsage(page, ["31% 5h"]);
await togglePin(screen, "Codex", "Weekly");
await expectPinnedUsage(page, ["31%", "12%"]);
await expectPinnedUsage(page, ["31% 5h", "12% wk"]);
await expect(usageItem(page)).not.toHaveText("Usage");
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Usage options", exact: true })).toBeVisible();
await qaScreenshot(page, "usage-screen-header", {
kind: "element",
locator: page.getByTestId("page-title").locator(".."),
@@ -95,7 +139,8 @@ test.describe("Usage item", () => {
await test.step("remaining flips the Usage item and the Usage screen", async () => {
await showUsageAs(page, "remaining");
await expectPinnedUsage(page, ["69% left", "88% left"]);
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
await expect(
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
).toBeVisible();
@@ -117,7 +162,7 @@ test.describe("Usage item", () => {
await leaveUsageScreen(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await expectPinnedUsage(page, ["69% left", "88% left"]);
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await qaScreenshot(page, "compact-footer");
await usageItem(page).click();
const sheet = usageSheet(page);
@@ -129,11 +174,19 @@ test.describe("Usage item", () => {
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the Usage screen's controls, host selector included.
await expect(
page.locator('[data-testid="usage-host-switcher"]:visible'),
).toHaveAccessibleName(/^Usage host: .+/);
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
// The sheet carries the Usage screen's controls.
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
// The options open as a sheet of their own over the usage sheet.
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-checked",
"true",
);
await qaScreenshot(page, "compact-options-sheet");
// Tap the backdrop above both sheets; only the options sheet closes.
await page.mouse.click(COMPACT.width / 2, 40);
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
await expect(page).not.toHaveURL(/\/usage$/);
const sheetBox = (await sheet.boundingBox())!;
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
@@ -150,10 +203,14 @@ test.describe("Usage item", () => {
await test.step("a reload keeps the pins and the toggle", async () => {
await page.reload();
await expectPinnedUsage(page, ["69% left", "88% left"]);
await expect(
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
).toHaveAttribute("aria-selected", "true");
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-checked",
"true",
);
await qaScreenshot(page, "usage-options-menu");
await page.keyboard.press("Escape");
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
@@ -167,13 +224,13 @@ test.describe("Usage item", () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => {
await test.step("unpinning both brings back default windows, and that survives a reload", async () => {
await togglePin(screen, "Claude", "Session");
await togglePin(screen, "Codex", "Weekly");
await expectNoPinnedUsage(page);
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
await page.reload();
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectNoPinnedUsage(page);
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
await gotoAppShell(page);
await usageItem(page).click();
await expectOnUsageScreen(page);
@@ -190,3 +247,53 @@ test.describe("Usage item", () => {
});
});
});
test("the Usage options show and hide the sidebar Usage item", async ({ page }) => {
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
await usageItem(page).click();
await expectOnUsageScreen(page);
await openUsageOptions(page);
const toggle = page.getByTestId("usage-show-in-sidebar");
await expect(toggle).toHaveAttribute("aria-checked", "true");
await toggle.click();
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
await page.reload();
await expectOnUsageScreen(page);
await openUsageOptions(page);
await expect(toggle).toHaveAttribute("aria-checked", "false");
await toggle.click();
await expect(usageItem(page)).toBeVisible();
});
test("released hosts supply source logos through the client conversion", async ({ page }) => {
await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
providerUsageListOnly: true,
});
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
// Source logos are decorative SVGs with no accessible role. Their path data distinguishes
// the source artwork from the fallback gauge.
const claudePath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
const codexPath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!;
const summary = usageItem(page).getByTestId("sidebar-usage-source");
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
await usageItem(page).click();
await expectOnUsageScreen(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(
screen.getByTestId("usage-report-claude").locator("svg path").first(),
).toHaveAttribute("d", claudePath);
await expect(
screen.getByTestId("usage-report-codex").locator("svg path").first(),
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-screen");
});
@@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
Keep generated screenshots, recordings, and run logs outside the repository.
## Stacked adaptive sheets
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
confirmation. It first types without tapping the input to verify autofocus, then cancels the
confirmation, closes and reopens the underlying sheet, confirms again,
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
bottom sheet and centered dialog are exercised.
These integration journeys require the host-confirmation flow in the test app. Include both the
sheet fix and that flow in the QA checkout when verifying them; the production sheet component
has no dependency on the host-confirmation feature.
Use a fresh English app with notification permission granted and first-use keyboard prompts
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
daemon accepts relay connections with no credential.
```sh
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
--udid "$SHEET_QA_IOS_UDID" \
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
--env OUT="$SHEET_QA_OUTPUT"
```
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
first. Use a second loopback spelling
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
Android uses the same journeys: copy each script outside the repository, change its context's
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
default mobile suite, alongside the existing modal-sheet scripts.
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
URI one character at a time passed with both implementations; faster typing remains unverified.
On iPad, replacing the field a second time appends text. For this Settings journey, use the
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
runs end to end without these adjustments.
@@ -0,0 +1,50 @@
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
context platform=ios timeout=180000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"welcome-paste-pairing-link\"" 20000
screenshot "${OUT}/01-welcome.png"
press "id=\"welcome-paste-pairing-link\""
wait stable
# Type without tapping the input: opening the sheet must retain its autofocus.
wait "id=\"pair-link-input\"" 10000
type "relay://"
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
wait stable
screenshot "${OUT}/02-pairing-input.png"
press "id=\"pair-link-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/03-confirmation-on-top.png"
press "id=\"host-confirmation-cancel\""
wait stable
wait "id=\"pair-link-submit\" enabled=true" 10000
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
# Close and reopen the underlying sheet, then edit it and submit again.
press "id=\"pair-link-cancel\""
wait stable
wait "id=\"welcome-paste-pairing-link\"" 10000
screenshot "${OUT}/05-underlying-sheet-closes.png"
press "id=\"welcome-paste-pairing-link\""
wait stable
wait "id=\"pair-link-input\"" 10000
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
wait stable
press "id=\"pair-link-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/06-confirmation-reopens.png"
press "id=\"host-confirmation-connect\""
wait stable
wait "id=\"pair-link-password-input\"" 20000
screenshot "${OUT}/07-password-after-connect.png"
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
wait stable
press "id=\"pair-link-submit\""
wait stable
wait text "Open a folder on your machine" 30000
screenshot "${OUT}/08-connected.png"
@@ -0,0 +1,46 @@
# Settings with Add host visible: no saved hosts, or the host picker already open.
context platform=ios timeout=180000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"settings-add-host\"" 20000
screenshot "${OUT}/09-settings.png"
press "id=\"settings-add-host\""
wait stable
wait "id=\"add-host-method-direct\"" 10000
screenshot "${OUT}/10-add-host-methods.png"
press "id=\"add-host-method-direct\""
wait stable
press "id=\"direct-host-advanced-toggle\""
wait stable
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
wait stable
screenshot "${OUT}/11-add-host-relay-input.png"
press "id=\"direct-host-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
press "id=\"host-confirmation-cancel\""
wait stable
wait "id=\"direct-host-submit\" enabled=true" 10000
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
wait stable
press "id=\"direct-host-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
press "id=\"host-confirmation-connect\""
wait stable
wait "id=\"direct-password-input\"" 20000
screenshot "${OUT}/15-add-host-password.png"
fill "id=\"direct-password-input\"" "${PASSWORD}"
wait stable
press "id=\"direct-host-submit\""
wait stable
open "${APP_ID}" "paseo://settings"
wait "label=\"Connections\"" 10000
press "label=\"Connections\""
wait stable
wait "id=\"host-page-connections-card\"" 30000
screenshot "${OUT}/16-settings-connected.png"
@@ -47,7 +47,7 @@ async function openAddProjectFlowSurface(
page: Page,
expectedPage: "host" | "method",
): Promise<void> {
await page.getByTestId("sidebar-add-project").click();
await page.locator('[data-testid="sidebar-add-project"]:visible').click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await expectAddProjectPage(page, expectedPage);
}
@@ -0,0 +1,95 @@
import { expect, type Locator, type Page } from "@playwright/test";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export interface LinkedHost {
serverId: string;
relayEndpoint: string;
daemonPublicKeyB64: string;
}
/**
* A pairing link that opens on the settings page. `/` redirects to
* `/open-project` on its own, so starting in settings leaves the pairing link
* as the only thing that can move the app to `/open-project`.
*/
export function buildPairingLink(host: LinkedHost): string {
const offer = {
v: 2,
serverId: host.serverId,
daemonPublicKeyB64: host.daemonPublicKeyB64,
relay: { endpoint: host.relayEndpoint, useTls: false },
};
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
return `/settings/general#offer=${encoded}`;
}
/** Loads the app from a pairing link, the way opening the link in a browser does. */
export async function openPairingLink(page: Page, link: string): Promise<void> {
// Leave the link's page first so the link always loads the app from scratch.
await page.goto("/");
await page.goto(link);
}
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
await page.goto("/");
const nowIso = new Date().toISOString();
const connectionId = `relay:${host.relayEndpoint}`;
const savedHost = {
serverId: host.serverId,
label: host.serverId,
connections: [
{
id: connectionId,
type: "relay",
relayEndpoint: host.relayEndpoint,
useTls: false,
daemonPublicKeyB64: host.daemonPublicKeyB64,
},
],
preferredConnectionId: connectionId,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
key: EXTRA_HOSTS_KEY,
hosts: [savedHost],
});
}
export function hostConfirmation(page: Page): Locator {
return page.getByTestId("host-confirmation");
}
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
const sheet = hostConfirmation(page);
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
}
export async function cancelHostConfirmation(page: Page): Promise<void> {
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
await expect(hostConfirmation(page)).toHaveCount(0);
}
export async function connectToConfirmedHost(page: Page): Promise<void> {
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
await expect(hostConfirmation(page)).toHaveCount(0);
}
/** Checks the host picker on the settings page the app is already showing. */
export async function expectHostInHostPicker(
page: Page,
serverId: string,
expected: "listed" | "not listed",
): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
expected === "listed" ? 1 : 0,
);
await page.keyboard.press("Escape");
}
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { openCommandCenter } from "./command-center";
import { openGlobalNewWorkspaceComposer } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
@@ -18,16 +19,24 @@ export class ImportSessionFlow {
timeout: 30_000,
});
}
async revealMobileEntryPoint() {
await openMobileAgentSidebar(this.page);
await expectMobileAgentSidebarVisible(this.page);
const button = this.page.getByTestId("sidebar-import-session");
/** The new workspace screen's Import session button, reached from the sidebar. */
async revealNewWorkspaceEntryPoint() {
const compact = (this.page.viewportSize()?.width ?? 0) < 768;
if (compact) {
await openMobileAgentSidebar(this.page);
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session");
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
async openGlobally() {
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
await this.page.getByTestId("sidebar-import-session").click();
await this.revealNewWorkspaceEntryPoint();
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -108,8 +117,12 @@ export class ImportSessionFlow {
});
}
async expectTranscript(userText: string, assistantText: string) {
await expect(this.page.getByTestId("user-message")).toContainText(userText);
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText(
userText,
);
await expect(
this.page.getByTestId("assistant-message").filter({ visible: true }),
).toContainText(assistantText);
}
async showAll() {
await this.page.getByTestId("import-session-show-all").click();
@@ -120,7 +133,9 @@ export class ImportSessionFlow {
timeout: 30_000,
});
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
await expect(workspace.getByTestId("user-message")).toContainText(userText);
await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText(
userText,
);
}
async close() {
await this.page.keyboard.press("Escape");
@@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer(
}
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").click();
// The shell keeps a hidden copy of the other layout's sidebar mounted.
await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click();
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
timeout: 30_000,
@@ -1,3 +1,4 @@
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
@@ -218,24 +219,64 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise<v
}
const FOOTER_ICON_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-import-session",
"sidebar-help",
"sidebar-settings",
];
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
/**
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
);
const [first, ...rest] = boxes.map((box) => box!);
let previous = first;
for (const box of rest) {
expect(Math.abs(box.y - first.y)).toBeLessThan(2);
expect(box.x).toBeGreaterThan(previous.x);
previous = box;
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
expect(box.width).toBe(first!.width);
}
const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width));
expect(gaps).toEqual([0, 0, expect.any(Number), 0]);
expect(gaps[2]).toBeGreaterThan(first!.width);
}
export async function expectFooterSeparator(page: Page, shown: boolean): Promise<void> {
await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS(
"border-top-width",
"1px",
);
await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS(
"border-top-width",
"0px",
);
const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible');
await expect(separator).toHaveCount(shown ? 1 : 0);
expect(
await separator.evaluateAll((elements) =>
elements.map((element) => getComputedStyle(element).borderBottomWidth),
),
).toEqual(shown ? ["1px"] : []);
}
export async function hoverFooterAddProject(page: Page): Promise<void> {
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
const tooltip = page.getByTestId("sidebar-add-project-tooltip");
await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible();
await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible();
}
export async function footerScreenshot(page: Page, name: string): Promise<void> {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
@@ -163,7 +163,6 @@ class StartupAssertions {
await expect(hostRow).toBeVisible({ timeout: 15_000 });
await expect(hostRow).toContainText(input.label);
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
@@ -1,20 +1,30 @@
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
export interface UsageListRequest {
forceRefresh: boolean;
reportIds?: string[];
}
export interface UsageReportsFixture {
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
listRequests(): UsageListRequest[];
waitForListRequests(count: number): Promise<void>;
}
interface UsageReportsFixtureOptions {
/**
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
* request; a function builds the response from the request when it arrives (e.g. a fresh
* `fetchedAt`, or a different answer to a forced refresh).
*/
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
/** False simulates a host with no usage reporting capability. */
usageSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
port?: number;
}
type UsageListResponse = UsageReportEntry[] | { error: string };
@@ -38,7 +48,11 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
return envelope.message as Record<string, unknown>;
}
function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null {
function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: { type?: unknown; payload?: Record<string, unknown> };
@@ -59,7 +73,11 @@ function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): s
...envelope.message,
payload: {
...payload,
features: { ...features, usageSources: enabled, providerUsageList: enabled },
features: {
...features,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
},
},
});
@@ -93,23 +111,30 @@ export async function installUsageReportsFixture(
page: Page,
options: UsageReportsFixtureOptions,
): Promise<UsageReportsFixture> {
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
const listRequests: UsageListRequest[] = [];
const listCounter = createCounter();
const usageSupported = options.usageSupported ?? true;
const providerUsageListOnly = options.providerUsageListOnly ?? false;
const requestType = providerUsageListOnly
? "provider.usage.list.request"
: "usage.list_reports.request";
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const route =
options.port === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(`${options.port}`);
await page.routeWebSocket(route, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const request = getSessionMessage(message);
const requestId = request?.requestId;
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
listRequests.push({
if (request?.type === requestType && typeof requestId === "string") {
const listRequest: UsageListRequest = {
forceRefresh: request.forceRefresh === true,
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
});
};
listRequests.push(listRequest);
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
const response = typeof scripted === "function" ? scripted() : scripted;
const response = typeof scripted === "function" ? scripted(listRequest) : scripted;
if ("error" in response) {
ws.send(
JSON.stringify({
@@ -118,7 +143,7 @@ export async function installUsageReportsFixture(
type: "rpc_error",
payload: {
requestId,
requestType: "usage.list_reports.request",
requestType,
error: response.error,
code: "transport",
},
@@ -130,12 +155,23 @@ export async function installUsageReportsFixture(
}
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
ws.send(
JSON.stringify({
type: "session",
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
}),
);
const reply = providerUsageListOnly
? {
type: "provider.usage.list.response",
payload: {
requestId,
fetchedAt: new Date().toISOString(),
providers: reports.map((entry) => ({
providerId: entry.sourceId,
displayName: entry.sourceLabel,
fetchedAt: entry.fetchedAt,
...entry.report,
planLabel: entry.report.planLabel ?? null,
})),
},
}
: { type: "usage.list_reports.response", payload: { requestId, reports } };
ws.send(JSON.stringify({ type: "session", message: reply }));
listCounter.increment();
return;
}
@@ -144,7 +180,9 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null;
typeof message === "string"
? withUsageSupportFeature(message, usageSupported, providerUsageListOnly)
: null;
ws.send(serverInfo ?? message);
});
});
@@ -1,7 +1,59 @@
import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, type Locator, type Page } from "@playwright/test";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
export async function installTallUsageSource() {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const cleanup = async () => {
try {
await client.removePlugin("tall-usage");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
} finally {
await client.close();
await rm(directory, { recursive: true, force: true });
}
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "tall-usage",
requirements: pluginRequirements,
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
import { z } from "zod";
export default function contribute(server) {
server.registerUsageSource({
id: "tall-usage", label: "Scrolling account", input: z.object({}),
discover: async () => [{}],
identify: async () => ({ key: "scrolling-account" }),
fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({
id: String(i), label: "Window " + (i + 1), usedPct: 25,
})) }),
});
return () => {};
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
return { cleanup };
} catch (error) {
await cleanup();
throw error;
}
}
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
@@ -28,8 +80,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
status: "available",
planLabel: "Max",
windows: [
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
{
id: "five_hour",
label: "Session",
shortLabel: "5h",
summary: true,
usedPct: 31,
resetsAt: inOneDay(),
},
{
id: "weekly",
label: "Weekly",
shortLabel: "wk",
summary: true,
usedPct: 54,
resetsAt: inOneDay(),
},
],
},
},
@@ -44,8 +110,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
status: "available",
planLabel: "Pro",
windows: [
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
{
id: "session",
label: "Session",
shortLabel: "5h",
summary: true,
usedPct: 7,
resetsAt: inOneDay(),
},
{
id: "weekly",
label: "Weekly",
shortLabel: "wk",
summary: true,
usedPct: 12,
resetsAt: inOneDay(),
},
],
},
},
@@ -71,9 +151,10 @@ export async function expectOnUsageScreen(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/usage$/);
}
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
await expect(windows).toHaveText(percents);
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
await expect(pinned).toHaveText(windows);
}
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
@@ -95,8 +176,21 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
/** The usage title row's options menu: Refresh and Used/Remaining. */
export async function openUsageOptions(page: Page): Promise<void> {
await page.locator('[data-testid="usage-options-menu"]:visible').first().click();
await expect(page.getByTestId("usage-display-used")).toBeVisible();
}
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
await openUsageOptions(page);
await page.getByTestId(`usage-display-${displayAs}`).click();
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
}
export async function refreshAllUsage(page: Page): Promise<void> {
await openUsageOptions(page);
await page.getByRole("menuitem", { name: "Refresh", exact: true }).click();
}
/** Opens the compact sidebar drawer. */
-11
View File
@@ -1,12 +1,10 @@
const { getDefaultConfig } = require("expo/metro-config");
const { resolve } = require("metro-resolver");
const fs = require("fs");
const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -81,15 +79,6 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
const origin = context.originModulePath;
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {
return resolveWithCustomWebOverlay(context, tsModuleName, platform);
}
}
return resolveWithCustomWebOverlay(context, moduleName, platform);
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
+2
View File
@@ -45,6 +45,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
@@ -606,6 +607,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
+43 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
@@ -7,10 +7,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "@/hosts/pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 4,
borderBottomRightRadius: 12,
},
scanAgainButton: {
marginTop: theme.spacing[6],
},
helperText: {
marginTop: theme.spacing[6],
color: theme.colors.foregroundMuted,
@@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
const raw = typeof result.data === "string" ? result.data.trim() : "";
if (!raw) return null;
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
return raw;
return null;
}
export default function PairScanScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -129,9 +124,6 @@ export default function PairScanScreen() {
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
const navigateToPairedHost = useCallback(
(serverId: string) => {
@@ -144,6 +136,19 @@ export default function PairScanScreen() {
[router, source],
);
const [scan] = useState(() =>
openPairScan({
importConnectionLink: (link) =>
getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
}),
);
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
@@ -159,30 +164,12 @@ export default function PairScanScreen() {
}, [permission, requestPermission]);
const handleScan = useCallback(
(result: BarcodeScanningResult) => {
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
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, passwordOfferUrl, source],
(result: BarcodeScanningResult) =>
scan.scan(typeof result.data === "string" ? result.data : ""),
[scan],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
@@ -240,24 +227,38 @@ export default function PairScanScreen() {
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
onBarcodeScanned={handleScan}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.overlay} pointerEvents="box-none">
<View style={styles.scanFrame}>
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
{scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
style={styles.scanAgainButton}
onPress={scan.scanAgain}
testID="pair-scan-again"
>
{t("pairing.connectionMethods.scanQr.title")}
</Button>
) : null}
</View>
</View>
)}
</View>
<PairLinkModal
visible={passwordOfferUrl !== null}
initialUrl={passwordOfferUrl ?? undefined}
initialPasswordRequired
onClose={closePasswordModal}
visible={scanState.status === "passwordRequired"}
passwordRequired={
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
}
onClose={scan.closePassword}
onSaved={savePasswordPairing}
/>
</View>
@@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
BottomSheetBackdrop,
BottomSheetFlatList,
type BottomSheetFlatListMethods,
} from "@gorhom/bottom-sheet";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -718,12 +714,6 @@ export function CommandCenter() {
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
});
const backdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
if (showBottomSheet) {
return (
@@ -735,7 +725,7 @@ export function CommandCenter() {
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={backdrop}
backdropOpacity={0.45}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.sheetHandle}
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, Text, View } from "react-native";
import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -13,13 +13,11 @@ import {
useWebOverlayRegistration,
} from "../lib/overlay-root";
import {
BottomSheetBackdrop,
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -75,11 +73,15 @@ export interface SheetHeader {
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
const styles = StyleSheet.create((theme) => ({
nativeModalRoot: {
nativeDialogSurface: {
flex: 1,
},
nativeDialogBackground: {
backgroundColor: "transparent",
},
desktopOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.55)",
@@ -471,7 +473,7 @@ export interface AdaptiveModalSheetProps {
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null;
}
@@ -526,33 +528,26 @@ export function AdaptiveModalSheet({
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
useEffect(() => {
if (!isWeb && visible) {
// A newly opened sheet owns input. A keyboard belonging to the sheet below
// would cover controls in the new sheet, which has no focused input yet.
Keyboard.dismiss();
}
}, [visible]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
isEnabled: isMobile || !isWeb,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
const nativeModalDismissNotifiedRef = useRef(!visible);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
onDismiss?.();
}, [handleSheetDismiss, onDismiss]);
const notifyNativeModalDismiss = useCallback(() => {
if (nativeModalDismissNotifiedRef.current) {
return;
}
nativeModalDismissNotifiedRef.current = true;
onDismiss?.();
}, [onDismiss]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
const desktopCardStyle = useMemo(
() => [
@@ -592,12 +587,6 @@ export function AdaptiveModalSheet({
onKeyDown: handleWebOverlayKeyDown,
});
useEffect(() => {
if (visible) {
nativeModalDismissNotifiedRef.current = false;
}
}, [visible]);
useEffect(() => {
if (!isWeb || isMobile) return;
if (visible) {
@@ -615,12 +604,6 @@ export function AdaptiveModalSheet({
return () => window.clearTimeout(timeout);
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
useEffect(() => {
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
const timeout = setTimeout(notifyNativeModalDismiss, 0);
return () => clearTimeout(timeout);
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
const sheetContent = (
<>
@@ -658,7 +641,7 @@ export function AdaptiveModalSheet({
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
@@ -725,18 +708,28 @@ export function AdaptiveModalSheet({
}
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
onDismiss={notifyNativeModalDismiss}
hardwareAccelerated
// Both native presentations share Gorhom's app-wide stack. Independent RN Modals
// present from their React ancestor's controller, so a root-owned sibling dialog
// cannot present while that controller already has a dialog open on iOS.
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={contextBridge}
snapPoints={NATIVE_DIALOG_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleDismiss}
handleComponent={null}
backgroundStyle={styles.nativeDialogBackground}
enablePanDownToClose={false}
enableHandlePanningGesture={false}
enableContentPanningGesture={false}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
{/* Android Modal opens a separate window outside the app's gesture root. */}
<GestureHandlerRootView style={styles.nativeModalRoot}>
{desktopContent}
</GestureHandlerRootView>
</Modal>
<View style={styles.nativeDialogSurface}>{desktopContent}</View>
</IsolatedBottomSheetModal>
);
}
+21 -25
View File
@@ -294,11 +294,23 @@ export interface AddHostModalProps {
}
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
return (
<AddHostModalContent
key={String(visible)}
visible={visible}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
/>
);
}
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
useHostMutations();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
const [linkPairing] = useState(() => beginLinkPairing());
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
@@ -313,18 +325,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const clearInput = useCallback(() => {
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
advancedTarget.current = new PairingTargetTracker("", true);
bumpInputResetKey();
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
@@ -358,29 +358,25 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
// Each open mounts a fresh form (see AddHostModal), so closing needs no reset.
const handleClose = useCallback(() => {
if (isSaving) return;
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, clearInput, onClose]);
}, [isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
}, [isSaving, onCancel, onClose]);
const handleSaveRelay = useCallback(
async (relayUri: string) => {
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
relayUri,
password || undefined,
);
const result = await linkPairing.submit(relayUri, password || undefined);
if (result.status === "cancelled") return;
const { profile, serverId, hostname } = result;
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
@@ -396,9 +392,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
daemons,
directConnectionLabels.invalidConnection,
handleClose,
linkPairing,
onSaved,
password,
probeAndUpsertConnectionFromOfferUrl,
],
);
@@ -2,13 +2,13 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { HostProfile } from "@/types/host-connection";
import type { Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
HostStatusDotSlot,
type HostPickerHost,
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
@@ -16,7 +16,7 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
hosts: HostProfile[];
hosts: HostPickerHost[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
/**
@@ -29,9 +29,9 @@ export interface HostFilterProps {
}
/**
* The "All hosts / <host>" filter pill shared by the History and Schedules
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
* when only one host exists. Copies the History layout exactly.
* The "All hosts / <host>" host pill shared by History, Schedules, Usage, the label manager and
* plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without
* `includeAllHost` it picks the one host a surface shows.
*/
export function HostFilter({
hosts,
@@ -24,7 +24,7 @@ export {
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
interface HostPickerHost {
export interface HostPickerHost {
serverId: string;
label: string;
}
@@ -1,83 +0,0 @@
import { ChevronDown } from "lucide-react-native";
import { useCallback, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { HostPicker } from "./host-picker";
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface HostSwitcherHost {
serverId: string;
label: string;
}
/** A compact trigger showing the chosen host, opening the host picker to change it. */
export function HostSwitcher({
hosts,
value,
onSelect,
title,
accessibilityLabel,
testID,
}: {
hosts: HostSwitcherHost[];
value: string;
onSelect: (serverId: string) => void;
/** The picker's title. */
title: string;
/** Prefixes the selected host's label: "Plugin host: Laptop". */
accessibilityLabel: string;
testID: string;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<View | null>(null);
const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value;
const openPicker = useCallback(() => setOpen(true), []);
return (
<HostPicker
hosts={hosts}
value={value}
onSelect={onSelect}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
title={title}
desktopPlacement="bottom-start"
>
<View ref={anchorRef} collapsable={false}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${accessibilityLabel}: ${selectedLabel}`}
testID={testID}
onPress={openPicker}
style={styles.trigger}
>
<Text numberOfLines={1} style={styles.label}>
{selectedLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
maxWidth: 180,
minHeight: 32,
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
+70 -138
View File
@@ -1,5 +1,5 @@
import { router } from "expo-router";
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -8,7 +8,6 @@ import {
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
@@ -23,6 +22,7 @@ import {
} from "@/components/sidebar-resize-handle-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
@@ -44,6 +44,7 @@ import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useHosts } from "@/runtime/host-runtime";
import { PluginSidebarItem } from "@/plugins/sidebar-items";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
@@ -51,7 +52,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem } from "@/usage";
import { UsageSidebarItem, useOpenUsageScreen } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -88,9 +89,9 @@ interface SidebarSharedProps {
interface SidebarLabels {
addProject: string;
hosts: string;
importSession: string;
settings: string;
searchHosts: string;
usage: string;
closeSidebar: string;
}
@@ -195,9 +196,9 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
hosts: t("sidebar.actions.hosts"),
importSession: t("importSession.title"),
settings: t("sidebar.actions.settings"),
searchHosts: t("sidebar.host.searchPlaceholder"),
usage: t(builtinSidebarNavLabelKey("usage")),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
@@ -282,6 +283,7 @@ function FooterIconButton({
testID: string;
label: string;
icon: typeof FolderPlus;
/** Only for a glyph that reads larger than the others at the same size. */
iconSize?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
@@ -309,65 +311,7 @@ function FooterIconButton({
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
function footerAddProjectButtonStyle({
hovered,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
}
function FooterAddProjectButton({
onPress,
label,
shortcutKeys,
theme,
}: {
onPress: () => void;
label: string;
shortcutKeys: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
}) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={footerAddProjectButtonStyle}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => {
const isHovered = Boolean(hovered);
return (
<>
<FolderPlus
size={theme.iconSize.sm}
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
numberOfLines={1}
style={[
styles.footerAddProjectLabel,
isHovered && styles.footerAddProjectLabelHovered,
]}
>
{label}
</Text>
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<TooltipContent side="top" align="center" offset={8} testID={`${testID}-tooltip`}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
@@ -422,7 +366,8 @@ function SidebarHostPicker({
testID="sidebar-hosts-trigger"
label={label}
icon={Server}
iconSize={theme.iconSize.sm}
// Server's two boxes fill more of the square than the other glyphs.
iconSize={theme.iconSize.md - 1}
theme={theme}
/>
</HostPicker>
@@ -447,7 +392,6 @@ function IconTooltipContent({
function SidebarFooter({
theme,
handleOpenProject,
handleImportSession,
handleSettings,
labels,
handleAddHost,
@@ -456,14 +400,13 @@ function SidebarFooter({
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleImportSession: () => void;
handleSettings: () => void;
labels: {
addProject: string;
hosts: string;
importSession: string;
settings: string;
searchHosts: string;
usage: string;
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
@@ -471,30 +414,35 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const openUsageScreen = useOpenUsageScreen();
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
return (
<View style={styles.sidebarFooter}>
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.footerIconRow}>
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={openUsageScreen}
testID="sidebar-usage-icon"
label={labels.usage}
icon={CircleGauge}
theme={theme}
/>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleImportSession}
testID="sidebar-import-session"
label={labels.importSession}
icon={Import}
theme={theme}
/>
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
@@ -516,21 +464,24 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
const visibleItems = items.filter((item) => item.visible);
if (visibleItems.length === 0) return null;
return (
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
{visibleItems.map((item) =>
item.kind === "plugin" ? (
<PluginSidebarItem
key={item.key}
group={item.group}
section="footer"
fallbackAnchorRef={rowsRef}
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
),
)}
</View>
<>
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
{visibleItems.map((item) =>
item.kind === "plugin" ? (
<PluginSidebarItem
key={item.key}
group={item.group}
section="footer"
fallbackAnchorRef={rowsRef}
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
),
)}
</View>
<SidebarSeparator testID="sidebar-footer-separator" />
</>
);
}
@@ -587,7 +538,11 @@ function MobileSidebar({
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<WindowChromeSafeArea
placement="inline"
pointerEvents="box-none"
style={styles.mobileCloseButtonRow}
>
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -636,7 +591,6 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
@@ -814,7 +768,6 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
@@ -913,7 +866,6 @@ const styles = StyleSheet.create((theme) => ({
right: 0,
zIndex: 2,
alignItems: "flex-end",
pointerEvents: "box-none",
},
mobileCloseButton: {
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
@@ -961,46 +913,26 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
sidebarFooter: {
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
footerContainer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
// Every footer row is 28px tall with `spacing[1]` between rows, the same as the gap above.
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
},
// Pushes Help and Settings to the end of the icon line.
footerSpacer: {
flex: 1,
},
// Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows.
footerRows: {
gap: theme.spacing[1],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
// The leading glyph sits on the footer icons' rail: a 16px icon centered in a 28px button
// starts 6px in, `spacing[1.5]`.
footerAddProjectButton: {
minWidth: 0,
minHeight: 28,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1.5],
borderRadius: theme.borderRadius.lg,
},
footerAddProjectButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
footerAddProjectLabel: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
footerAddProjectLabelHovered: {
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: {
width: 28,
+15 -26
View File
@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
@@ -62,8 +62,8 @@ const styles = StyleSheet.create((theme) => ({
export interface PairLinkModalProps {
visible: boolean;
initialUrl?: string;
initialPasswordRequired?: boolean;
/** Continues a confirmed pairing whose host asked for a password. */
passwordRequired?: PasswordRequiredPairing;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
@@ -76,18 +76,16 @@ export interface PairLinkModalProps {
export function PairLinkModal({
visible,
initialUrl,
initialPasswordRequired = false,
passwordRequired,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
return (
<PairLinkModalContent
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
key={`${visible}:${passwordRequired?.link ?? ""}`}
visible={visible}
initialUrl={initialUrl}
initialPasswordRequired={initialPasswordRequired}
passwordRequired={passwordRequired}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
@@ -97,8 +95,7 @@ export function PairLinkModal({
function PairLinkModalContent({
visible,
initialUrl,
initialPasswordRequired = false,
passwordRequired,
onClose,
onCancel,
onSaved,
@@ -106,7 +103,9 @@ function PairLinkModalContent({
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
const { beginLinkPairing } = useHostMutations();
const [pairing] = useState(() => passwordRequired?.pairing ?? beginLinkPairing());
const initialUrl = passwordRequired?.link;
const isMobile = useIsCompactFormFactor();
const offerUrlRef = useRef(initialUrl ?? "");
@@ -115,7 +114,7 @@ function PairLinkModalContent({
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [password, setPassword] = useState("");
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined);
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
@@ -162,10 +161,9 @@ function PairLinkModalContent({
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
raw,
password || undefined,
);
const result = await pairing.submit(raw, password || undefined);
if (result.status === "cancelled") return;
const { profile, serverId, hostname } = result;
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
@@ -184,16 +182,7 @@ function PairLinkModalContent({
setIsSaving(false);
}
},
[
daemons,
handleClose,
isMobile,
isSaving,
onSaved,
password,
t,
probeAndUpsertConnectionFromOfferUrl,
],
[daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing],
);
const handleChangeOfferUrl = useCallback((next: string) => {
@@ -89,7 +89,7 @@ export function SidebarHeaderRow({
accessibilityLabel={accessibilityLabel ?? label}
accessibilityState={isActive ? SELECTED_STATE : undefined}
aria-selected={isActive}
style={variant === "inline" ? styles.buttonInline : styles.button}
style={styles.button}
>
{ThemedIcon ? (
<ThemedIcon
@@ -158,17 +158,6 @@ const styles = StyleSheet.create((theme) => ({
// edge with the list below.
paddingHorizontal: theme.spacing[2],
},
// Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button.
buttonInline: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1.5],
},
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
label: {
@@ -6,8 +6,8 @@ import { StyleSheet } from "react-native-unistyles";
* padded container so the line runs the full sidebar width — matching the
* separator beneath the workspace sidebar's "New agent" header.
*/
export function SidebarSeparator() {
return <View style={styles.line} />;
export function SidebarSeparator({ testID }: { testID?: string } = {}) {
return <View style={styles.line} testID={testID} />;
}
const styles = StyleSheet.create((theme) => ({
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
@@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
setSheetData(null);
}, [handleSheetDismiss]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[],
);
const contextValue = useMemo(
() => ({ openToolCall, closeToolCall }),
[openToolCall, closeToolCall],
@@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleToolCallSheetDismiss}
backdropComponent={renderBackdrop}
backdropOpacity={0.5}
enablePanDownToClose
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
+2 -18
View File
@@ -28,11 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native";
@@ -948,18 +944,6 @@ interface MobileBodyProps {
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
const renderBackdrop = useCallback(
(backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...backdropProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[],
);
const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
@@ -991,7 +975,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
enableDynamicSizing={false}
onChange={props.handleSheetChange}
onDismiss={props.handleSheetDismiss}
backdropComponent={renderBackdrop}
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={props.handleIndicatorStyle}
@@ -1,9 +1,10 @@
import {
BottomSheetModal as GorhomBottomSheetModal,
type BottomSheetModalProps,
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
import {
@@ -11,6 +12,7 @@ import {
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
import { SheetBackdrop } from "./sheet-backdrop";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
@@ -36,7 +38,7 @@ export type ContextBridge = (children: ReactNode) => ReactNode;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior" | "children"
"enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent"
> & {
/**
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
@@ -44,6 +46,8 @@ type IsolatedBottomSheetModalProps = Omit<
*/
children?: ReactNode;
presentation?: "push" | "replace";
/** Show a dismissible backdrop. Its interaction belongs to the modal stack. */
backdropOpacity?: number;
/**
* Required, and `null` is a real answer: a sheet that needs nothing from its call site should
* have to say so. The failure it prevents is invisible until someone adds a `useContext` deep
@@ -58,13 +62,43 @@ export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) {
const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
// Gorhom puts this ref into its provider queue and reads `.current` for stack operations.
// A callback ref accepts the handle but leaves that queue unable to dismiss or restore it.
const modalRef = useRef<GorhomBottomSheetModalMethods>(null);
useImperativeHandle(
ref,
() => ({
present: (...args) => modalRef.current?.present(...args),
dismiss: (...args) => modalRef.current?.dismiss(...args),
snapToIndex: (...args) => modalRef.current?.snapToIndex(...args),
snapToPosition: (...args) => modalRef.current?.snapToPosition(...args),
expand: (...args) => modalRef.current?.expand(...args),
collapse: (...args) => modalRef.current?.collapse(...args),
close: (...args) => modalRef.current?.close(...args),
forceClose: (...args) => modalRef.current?.forceClose(...args),
}),
[],
);
const {
children,
presentation = "push",
contextBridge,
backdropOpacity,
...bottomSheetProps
} = props;
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
),
[backdropOpacity],
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
ref={modalRef}
enableDismissOnClose
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
>
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
</GorhomBottomSheetModal>
@@ -0,0 +1,5 @@
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
}
@@ -0,0 +1,36 @@
import {
BottomSheetBackdrop,
useBottomSheetModal,
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, StyleSheet, View } from "react-native";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
const { dismiss } = useBottomSheetModal();
const { t } = useTranslation();
const dismissTopSheet = useCallback(() => dismiss(), [dismiss]);
return (
<View style={StyleSheet.absoluteFill}>
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="none"
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("common.bottomSheetBackdrop")}
// The provider owns presentation order, including when a lower backdrop remounts.
// Keep this target until the portal unmounts: animation must not expose the sheet below.
onPress={dismissTopSheet}
style={StyleSheet.absoluteFill}
/>
</View>
);
}
@@ -5,14 +5,13 @@ import {
useEffect,
useMemo,
useRef,
type ComponentProps,
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { ChevronLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
@@ -345,6 +344,7 @@ function MenuSheetSurface({
testID,
keyboardFocusScope,
}: MenuSurfaceProps): ReactElement | null {
const { height: windowHeight } = useWindowDimensions();
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue } = useSubAnchors();
const safeAreaInsets = useSafeAreaInsets();
@@ -370,18 +370,6 @@ function MenuSheetSurface({
onClose: handleClose,
});
const renderBackdrop = useCallback(
(backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...backdropProps}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
/>
),
[],
);
const openPageId = currentPageId(menu.path);
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
const depth = menu.path.length;
@@ -405,9 +393,10 @@ function MenuSheetSurface({
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
enableDynamicSizing
maxDynamicContentSize={windowHeight * 0.8}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
backdropOpacity={0.45}
enablePanDownToClose
// `interactive` rather than `extend`, which is what every other sheet in the app uses.
// `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that
@@ -394,6 +394,7 @@ type AgentControlsSlice = {
runtimeModelId: string | null;
model: string | null | undefined;
features: AgentFeature[] | undefined;
runtimeThinkingOptionId: string | null;
thinkingOptionId: string | null | undefined;
lastUsage: unknown;
} | null;
@@ -413,6 +414,7 @@ function selectAgentControlsSlice(
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
features: currentAgent.features,
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
};
@@ -1594,6 +1596,7 @@ export const AgentControls = memo(function AgentControls({
models,
runtimeModelId: agent?.runtimeModelId,
configuredModelId: agent?.model,
runtimeThinkingOptionId: agent?.runtimeThinkingOptionId,
explicitThinkingOptionId: agent?.thinkingOptionId,
});
@@ -67,6 +67,7 @@ describe("resolveAgentModelSelection", () => {
],
runtimeModelId: null,
configuredModelId: "claude-fable-5[1m]",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: null,
});
@@ -88,6 +89,7 @@ describe("resolveAgentModelSelection", () => {
],
runtimeModelId: "a",
configuredModelId: "b",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: null,
});
@@ -112,6 +114,7 @@ describe("resolveAgentModelSelection", () => {
],
runtimeModelId: "a",
configuredModelId: null,
runtimeThinkingOptionId: null,
explicitThinkingOptionId: "high",
});
@@ -119,6 +122,54 @@ describe("resolveAgentModelSelection", () => {
expect(selection.displayThinking).toBe("High");
});
it("shows the thinking level the agent runs on when none was chosen", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "openrouter/google/gemini-3.6-flash",
provider: "pi",
label: "Gemini 3.6 Flash",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium", isDefault: true },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: "medium",
},
],
runtimeModelId: "openrouter/google/gemini-3.6-flash",
configuredModelId: null,
runtimeThinkingOptionId: "high",
explicitThinkingOptionId: null,
});
expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High");
});
it("falls back to the chosen thinking level when the runtime level is not a model option", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: "a",
configuredModelId: null,
runtimeThinkingOptionId: "xhigh",
explicitThinkingOptionId: "high",
});
expect(selection.selectedThinkingId).toBe("high");
});
it("formats raw thinking labels in the selected model display", () => {
const selection = resolveAgentModelSelection({
models: [
@@ -134,6 +185,7 @@ describe("resolveAgentModelSelection", () => {
],
runtimeModelId: "a",
configuredModelId: null,
runtimeThinkingOptionId: null,
explicitThinkingOptionId: "xhigh",
});
@@ -155,6 +207,7 @@ describe("resolveAgentModelSelection", () => {
],
runtimeModelId: null,
configuredModelId: null,
runtimeThinkingOptionId: null,
explicitThinkingOptionId: null,
});
@@ -178,6 +231,7 @@ describe("resolveAgentModelSelection", () => {
],
runtimeModelId: "claude-sonnet-4-6-20260101",
configuredModelId: "default",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: null,
});
@@ -186,4 +240,31 @@ describe("resolveAgentModelSelection", () => {
expect(selection.selectedThinkingId).toBe("low");
expect(selection.displayThinking).toBe("Low");
});
it("shows the agent's own model when the catalog does not list it", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "gpt-6-sol",
provider: "codex",
label: "GPT-6-Sol",
isDefault: true,
thinkingOptions: [
{ id: "medium", label: "medium" },
{ id: "max", label: "max" },
],
defaultThinkingOptionId: "medium",
},
],
runtimeModelId: "gpt-6.1-sol",
configuredModelId: "gpt-6.1-sol",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: "max",
});
expect(selection.selectedModel).toBeNull();
expect(selection.activeModelId).toBe("gpt-6.1-sol");
expect(selection.displayModel).toBe("gpt-6.1-sol");
expect(selection.displayThinking).toBe("Max");
});
});
@@ -79,16 +79,23 @@ function pickSelectedModel(
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
): AgentModelDefinition | null {
if (!models || !preferredModelId) {
if (!preferredModelId) {
return fallbackModel;
}
return findModelById(models, preferredModelId) ?? fallbackModel;
return findModelById(models, preferredModelId);
}
function resolveThinkingId(
runtimeThinkingOptionId: string | null | undefined,
explicitThinkingOptionId: string | null | undefined,
selectedModel: AgentModelDefinition | null,
): string | null {
const runtimeThinkingOption = selectedModel?.thinkingOptions?.find(
(option) => option.id === runtimeThinkingOptionId,
);
if (runtimeThinkingOption) {
return runtimeThinkingOption.id;
}
if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") {
return explicitThinkingOptionId;
}
@@ -139,9 +146,16 @@ export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
runtimeThinkingOptionId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
}) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const {
models,
runtimeModelId,
configuredModelId,
runtimeThinkingOptionId,
explicitThinkingOptionId,
} = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
@@ -162,9 +176,13 @@ export function resolveAgentModelSelection(input: {
);
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const resolvedThinkingId = resolveThinkingId(
runtimeThinkingOptionId,
explicitThinkingOptionId,
selectedModel,
);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const selectedThinkingId = effectiveThinking?.id ?? resolvedThinkingId;
const displayThinking = resolveThinkingDisplay(
effectiveThinking,
selectedThinkingId,
@@ -267,6 +267,7 @@ describe("loadAppSettingsFromStorage", () => {
expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({
displayAs: "used",
pinned: [],
serverId: null,
});
const deps = makeDeps({
@@ -278,6 +279,7 @@ describe("loadAppSettingsFromStorage", () => {
{ sourceId: "codex", windowId: "weekly" },
{ sourceId: "claude", windowId: "five-hour" },
],
serverId: "server",
},
}),
}),
@@ -289,6 +291,7 @@ describe("loadAppSettingsFromStorage", () => {
{ sourceId: "codex", windowId: "weekly" },
{ sourceId: "claude", windowId: "five-hour" },
],
serverId: "server",
});
});
@@ -296,7 +299,11 @@ describe("loadAppSettingsFromStorage", () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({
usage: { displayAs: "percent", pinned: [{ sourceId: "claude", windowId: "weekly" }] },
usage: {
displayAs: "percent",
pinned: [{ sourceId: "claude", windowId: "weekly" }],
serverId: 42,
},
}),
}),
});
@@ -304,6 +311,7 @@ describe("loadAppSettingsFromStorage", () => {
expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({
displayAs: "used",
pinned: [{ sourceId: "claude", windowId: "weekly" }],
serverId: null,
});
});
@@ -0,0 +1,109 @@
import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { useHostConfirmation } from "@/runtime/host-runtime";
/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */
export function HostConfirmationSheet() {
const { t } = useTranslation();
const { pending, answer } = useHostConfirmation();
const cancel = useCallback(() => answer(false), [answer]);
const connect = useCallback(() => answer(true), [answer]);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.hostConfirmation.title") }), [t]);
if (!pending) return null;
return (
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-confirmation">
<Text style={styles.confirmText}>
{pending.kind === "changedConnection"
? t("pairing.hostConfirmation.descriptionChanged")
: t("pairing.hostConfirmation.description")}
</Text>
<View style={settingsStyles.card}>
<DetailRow
label={t("pairing.hostConfirmation.hostLabel")}
value={pending.serverId}
testID="host-confirmation-server-id"
/>
<DetailRow
label={t("pairing.hostConfirmation.fingerprintLabel")}
value={pending.keyFingerprint}
testID="host-confirmation-fingerprint"
showBorder
/>
<DetailRow
label={t("pairing.hostConfirmation.relayLabel")}
value={pending.relayEndpoint}
testID="host-confirmation-relay"
showBorder
/>
</View>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={cancel}
testID="host-confirmation-cancel"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
style={FLEX_1_STYLE}
onPress={connect}
testID="host-confirmation-connect"
>
{t("pairing.hostConfirmation.connect")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
function DetailRow({
label,
value,
showBorder = false,
testID,
}: {
label: string;
value: string;
showBorder?: boolean;
testID?: string;
}) {
return (
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
<Text style={settingsStyles.rowHint} selectable testID={testID}>
{value}
</Text>
</View>
</View>
);
}
// Same shape as the remove-host and remove-connection confirm sheets in
// screens/settings/host-page.tsx.
const styles = StyleSheet.create((theme) => ({
confirmText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
confirmActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
}));
const FLEX_1_STYLE = { flex: 1 };
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime";
import { openPairScan, type PairScanModel } from "./pair-scan-model";
const LINK = "https://app.paseo.sh/#offer=abc";
const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) };
/** The host runtime's side of a scan: each import resolves with the next queued outcome. */
function createScanner(outcomes: Array<ConnectionLinkImport | Error>) {
const imports: string[] = [];
const connected: string[] = [];
const scan = openPairScan({
importConnectionLink: async (link) => {
imports.push(link);
const outcome = outcomes.shift();
if (!outcome) throw new Error("No outcome queued for this import");
if (outcome instanceof Error) throw outcome;
return outcome;
},
onConnected: (serverId) => connected.push(serverId),
});
return { scan, imports, connected };
}
async function settled(scan: PairScanModel): Promise<void> {
await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing"));
}
describe("openPairScan", () => {
it("stops scanning after Cancel until the user scans again", async () => {
const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]);
scan.scan(LINK);
await settled(scan);
// The code is still in front of the camera.
scan.scan(LINK);
scan.scan(LINK);
await Promise.resolve();
expect(imports).toEqual([LINK]);
expect(scan.getState()).toEqual({ status: "stopped", error: null });
scan.scanAgain();
scan.scan(LINK);
await settled(scan);
expect(imports).toEqual([LINK, LINK]);
});
it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => {
const { scan, imports, connected } = createScanner([
{ status: "connected", serverId: "srv_scan" },
]);
scan.scan("https://example.com/not-a-pairing-link");
expect(scan.getState()).toEqual({ status: "scanning" });
scan.scan(LINK);
scan.scan(LINK);
await settled(scan);
expect(imports).toEqual([LINK]);
expect(connected).toEqual(["srv_scan"]);
scan.scan(LINK);
expect(imports).toEqual([LINK]);
});
it("ends the attempt when the password prompt closes", async () => {
const { scan, imports } = createScanner([
{ status: "password_required", link: LINK, pairing: PAIRING },
]);
scan.scan(LINK);
await settled(scan);
expect(scan.getState()).toEqual({
status: "passwordRequired",
passwordRequired: { link: LINK, pairing: PAIRING },
});
scan.scan(LINK);
scan.closePassword();
scan.scan(LINK);
expect(scan.getState()).toEqual({ status: "stopped", error: null });
expect(imports).toEqual([LINK]);
});
it("stops with the error when an attempt fails", async () => {
const { scan, imports } = createScanner([new Error("Server disconnected")]);
scan.scan(LINK);
await settled(scan);
scan.scan(LINK);
expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" });
expect(imports).toEqual([LINK]);
});
it("does not navigate for an attempt that finishes after the screen closed", async () => {
let finish: (outcome: ConnectionLinkImport) => void = () => {};
const connected: string[] = [];
const scan = openPairScan({
importConnectionLink: () =>
new Promise((resolve) => {
finish = resolve;
}),
onConnected: (serverId) => connected.push(serverId),
});
scan.scan(LINK);
scan.close();
finish({ status: "connected", serverId: "srv_scan" });
await Promise.resolve();
expect(connected).toEqual([]);
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime";
/**
* One visit to the QR scan screen. The camera reports every frame that shows
* a code, so an attempt that ends (Cancel on the host confirmation, a closed
* password prompt, or an error) stops scanning until the user taps scan again.
* Without that, the code still in front of the camera starts a new attempt on
* the next frame.
*/
export type PairScanState =
| { status: "scanning" }
| { status: "pairing" }
| { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing }
| { status: "stopped"; error: string | null }
| { status: "connected" };
export interface PairScanModel {
getState: () => PairScanState;
subscribe: (listener: () => void) => () => void;
/** Data from a QR code the camera saw. Only a scanning session starts an attempt. */
scan: (data: string) => void;
/** The user asked to scan again after an attempt ended. */
scanAgain: () => void;
/** The user closed the password prompt, which ends the attempt. */
closePassword: () => void;
close: () => void;
}
export interface PairScanDependencies {
importConnectionLink: (link: string) => Promise<ConnectionLinkImport>;
onConnected: (serverId: string) => void;
}
export function openPairScan(deps: PairScanDependencies): PairScanModel {
let state: PairScanState = { status: "scanning" };
let closed = false;
const listeners = new Set<() => void>();
function publish(next: PairScanState): void {
if (closed) return;
state = next;
for (const listener of listeners) {
listener();
}
}
async function pair(link: string): Promise<void> {
publish({ status: "pairing" });
try {
const outcome = await deps.importConnectionLink(link);
if (closed) return;
if (outcome.status === "connected") {
publish({ status: "connected" });
deps.onConnected(outcome.serverId);
return;
}
if (outcome.status === "password_required") {
const { link: passwordLink, pairing } = outcome;
publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } });
return;
}
publish({ status: "stopped", error: null });
} catch (error) {
publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) });
}
}
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
scan(data) {
if (state.status !== "scanning") return;
const link = pairingLinkFromScan(data);
if (!link) return;
void pair(link);
},
scanAgain() {
if (state.status !== "stopped") return;
publish({ status: "scanning" });
},
closePassword() {
if (state.status !== "passwordRequired") return;
publish({ status: "stopped", error: null });
},
close() {
closed = true;
listeners.clear();
},
};
}
function pairingLinkFromScan(data: string): string | null {
const raw = data.trim();
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) {
return raw;
}
return null;
}
+12
View File
@@ -26,6 +26,7 @@ export const ar: TranslationResources = {
total: "{{total}} تطابقات",
},
common: {
bottomSheetBackdrop: "خلفية اللوحة السفلية",
back: "خلف",
loading: "تحميل...",
actions: {
@@ -1624,6 +1625,17 @@ export const ar: TranslationResources = {
title: "كلمة المرور لـ {{host}}",
label: "كلمة مرور المضيف",
},
hostConfirmation: {
title: "الاتصال بهذا المضيف؟",
description:
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
descriptionChanged:
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
hostLabel: "المضيف",
fingerprintLabel: "بصمة المفتاح",
relayLabel: "المُرحّل",
connect: "اتصال",
},
connectionMethods: {
title: "إضافة اتصال",
direct: {
+12
View File
@@ -22,6 +22,7 @@ export const en = {
total: "{{total}} matches",
},
common: {
bottomSheetBackdrop: "Bottom sheet backdrop",
back: "Back",
loading: "Loading...",
actions: {
@@ -1649,6 +1650,17 @@ export const en = {
title: "Password for {{host}}",
label: "Host password",
},
hostConfirmation: {
title: "Connect to this host?",
description:
"This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
descriptionChanged:
"This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
hostLabel: "Host",
fingerprintLabel: "Key fingerprint",
relayLabel: "Relay",
connect: "Connect",
},
connectionMethods: {
title: "Add connection",
direct: {
+12
View File
@@ -27,6 +27,7 @@ export const es: TranslationResources = {
total: "{{total}} coincidencias",
},
common: {
bottomSheetBackdrop: "Fondo del panel inferior",
back: "Atrás",
loading: "Cargando...",
actions: {
@@ -1670,6 +1671,17 @@ export const es: TranslationResources = {
title: "Contraseña de {{host}}",
label: "Contraseña del host",
},
hostConfirmation: {
title: "¿Conectar con este host?",
description:
"Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
descriptionChanged:
"Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
hostLabel: "Host",
fingerprintLabel: "Huella de la clave",
relayLabel: "Relay",
connect: "Conectar",
},
connectionMethods: {
title: "Agregar conexión",
direct: {
+12
View File
@@ -27,6 +27,7 @@ export const fr: TranslationResources = {
total: "{{total}} résultats",
},
common: {
bottomSheetBackdrop: "Arrière-plan du panneau inférieur",
back: "Dos",
loading: "Chargement...",
actions: {
@@ -1674,6 +1675,17 @@ export const fr: TranslationResources = {
title: "Mot de passe pour {{host}}",
label: "Mot de passe de l’hôte",
},
hostConfirmation: {
title: "Se connecter à cet hôte ?",
description:
"Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
descriptionChanged:
"Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
hostLabel: "Hôte",
fingerprintLabel: "Empreinte de la clé",
relayLabel: "Relais",
connect: "Se connecter",
},
connectionMethods: {
title: "Ajouter une connexion",
direct: {
+12
View File
@@ -27,6 +27,7 @@ export const ja: TranslationResources = {
total: "{{total}} 件の一致",
},
common: {
bottomSheetBackdrop: "ボトムシートの背景",
back: "戻る",
loading: "読み込み中...",
actions: {
@@ -1641,6 +1642,17 @@ export const ja: TranslationResources = {
title: "{{host}} のパスワード",
label: "ホストのパスワード",
},
hostConfirmation: {
title: "このホストに接続しますか?",
description:
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
descriptionChanged:
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
hostLabel: "ホスト",
fingerprintLabel: "鍵のフィンガープリント",
relayLabel: "リレー",
connect: "接続",
},
connectionMethods: {
title: "接続を追加",
direct: {
+12
View File
@@ -26,6 +26,7 @@ export const ko: TranslationResources = {
total: "일치 항목 {{total}}개",
},
common: {
bottomSheetBackdrop: "하단 시트 배경",
back: "뒤로",
loading: "불러오는 중...",
actions: {
@@ -1634,6 +1635,17 @@ export const ko: TranslationResources = {
title: "{{host}}의 비밀번호",
label: "호스트 비밀번호",
},
hostConfirmation: {
title: "이 호스트에 연결할까요?",
description:
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
descriptionChanged:
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
hostLabel: "호스트",
fingerprintLabel: "키 지문",
relayLabel: "릴레이",
connect: "연결",
},
connectionMethods: {
title: "연결 추가",
direct: {
+12
View File
@@ -27,6 +27,7 @@ export const ptBR: TranslationResources = {
total: "{{total}} correspondências",
},
common: {
bottomSheetBackdrop: "Fundo do painel inferior",
back: "Voltar",
loading: "Carregando...",
actions: {
@@ -1655,6 +1656,17 @@ export const ptBR: TranslationResources = {
title: "Senha de {{host}}",
label: "Senha do host",
},
hostConfirmation: {
title: "Conectar a este host?",
description:
"Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
descriptionChanged:
"Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
hostLabel: "Host",
fingerprintLabel: "Impressão da chave",
relayLabel: "Relay",
connect: "Conectar",
},
connectionMethods: {
title: "Adicionar conexão",
direct: {
+12
View File
@@ -27,6 +27,7 @@ export const ru: TranslationResources = {
total: "Совпадений: {{total}}",
},
common: {
bottomSheetBackdrop: "Фон нижней панели",
back: "Назад",
loading: "Загрузка...",
actions: {
@@ -1653,6 +1654,17 @@ export const ru: TranslationResources = {
title: "Пароль для {{host}}",
label: "Пароль хоста",
},
hostConfirmation: {
title: "Подключиться к этому хосту?",
description:
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
descriptionChanged:
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
hostLabel: "Хост",
fingerprintLabel: "Отпечаток ключа",
relayLabel: "Реле",
connect: "Подключить",
},
connectionMethods: {
title: "Добавить подключение",
direct: {
+11
View File
@@ -26,6 +26,7 @@ export const zhCN: TranslationResources = {
total: "{{total}} 个匹配项",
},
common: {
bottomSheetBackdrop: "底部面板背景",
back: "返回",
loading: "加载中...",
actions: {
@@ -1607,6 +1608,16 @@ export const zhCN: TranslationResources = {
title: "{{host}} 的密码",
label: "主机密码",
},
hostConfirmation: {
title: "连接到此主机?",
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
descriptionChanged:
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
hostLabel: "主机",
fingerprintLabel: "密钥指纹",
relayLabel: "中继",
connect: "连接",
},
connectionMethods: {
title: "添加连接",
direct: {
+8 -9
View File
@@ -9,7 +9,7 @@ import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostSwitcher } from "@/components/hosts/host-switcher";
import { HostFilter } from "@/components/hosts/host-filter";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
@@ -82,7 +82,7 @@ function SurfaceRenderer({
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
function PluginHostSwitcher({
function PluginHostFilter({
serverId,
pluginId,
identity,
@@ -117,13 +117,12 @@ function PluginHostSwitcher({
if (!show) return null;
return (
<HostSwitcher
<HostFilter
hosts={hosts}
value={serverId}
onSelect={selectHost}
title="Choose plugin host"
accessibilityLabel="Plugin host"
testID="plugin-host-switcher"
selectedHost={serverId}
onSelectHost={selectHost}
includeAllHost={false}
triggerTestID="plugin-host-filter-trigger"
/>
);
}
@@ -192,7 +191,7 @@ export function PluginSurfaceScreen() {
() => (
<>
{identity ? (
<PluginHostSwitcher
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
@@ -0,0 +1,97 @@
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import {
hostHasConnection,
relayConnectionFromOffer,
type HostProfile,
} from "@/types/host-connection";
/**
* The question shown before a pairing link adds a host that is not saved, or
* changes the key, relay, or TLS setting of a saved one.
*/
export interface HostConfirmationRequest {
/** Identifies this question, so an answer shown for it never answers a newer one. */
id: number;
serverId: string;
/** Grouped daemon public key the user can compare with the one their daemon shows. */
keyFingerprint: string;
relayEndpoint: string;
kind: "newHost" | "changedConnection";
}
interface PendingConfirmation {
request: HostConfirmationRequest;
resolve: (approved: boolean) => void;
}
/**
* Owns the one host confirmation the app is waiting on. The host runtime asks
* through `confirmLink`; the view reads `getPending` and replies with `answer`.
* A question waits until it is answered, whether or not a view is showing it.
*/
export class HostConfirmations {
private pending: PendingConfirmation | null = null;
private nextRequestId = 1;
private listeners = new Set<() => void>();
getPending(): HostConfirmationRequest | null {
return this.pending?.request ?? null;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/** Answers the request with `requestId`; an answer for any other request is ignored. */
answer(requestId: number, approved: boolean): void {
const current = this.pending;
if (!current || current.request.id !== requestId) return;
this.pending = null;
this.emit();
current.resolve(approved);
}
/** Resolves true without asking when the link matches a saved host exactly. */
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
const connection = relayConnectionFromOffer(offer);
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
return this.ask({
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
});
}
private ask(request: HostConfirmationRequest): Promise<boolean> {
// One question at a time: a newer one cancels the unanswered one.
this.pending?.resolve(false);
return new Promise<boolean>((resolve) => {
this.pending = { request, resolve };
this.emit();
});
}
private emit(): void {
for (const listener of this.listeners) {
listener();
}
}
}
/**
* Shows the key material itself (not a re-hash) in groups of four. Keys longer
* than 16 characters show only their first and last 8, so two keys that share
* both ends show the same value.
*/
function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
if (normalized.length <= 16) return group(normalized);
return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`;
}
+321 -110
View File
@@ -31,6 +31,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import type { HostConfirmationRequest } from "./host-confirmation";
it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = [];
@@ -507,6 +508,63 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
return client;
}
/**
* Answers every host confirmation the store asks, the way the sheet does, and
* records each request.
*/
function answerHostConfirmations(
store: HostRuntimeStore,
answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean),
): HostConfirmationRequest[] {
const requests: HostConfirmationRequest[] = [];
store.subscribeHostConfirmation(() => {
const pending = store.getPendingHostConfirmation();
if (!pending) return;
requests.push(pending);
store.answerHostConfirmation(
pending.id,
typeof answer === "function" ? answer(pending, requests.length - 1) : answer,
);
});
return requests;
}
function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest {
const pending = store.getPendingHostConfirmation();
if (!pending) throw new Error("Expected a pending host confirmation");
return pending;
}
function createPairingStore(
input: {
hostname?: string;
password?: string;
connectAttempts?: string[];
serverIdForHost?: (host: HostProfile) => string;
storage?: HostRuntimeStorage;
} = {},
): HostRuntimeStore {
return new HostRuntimeStore({
storage: input.storage ?? createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host, connection }) => {
input.connectAttempts?.push(connection.id);
if (input.password && host.password !== input.password) {
throw new DaemonAuthenticationError("password_required");
}
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: input.serverIdForHost?.(host) ?? host.serverId,
hostname: input.hostname ?? "paired",
};
},
getClientId: async () => "cid_pairing",
readInitialConnectionHint: () => null,
},
});
}
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
const values = new Map(Object.entries(entries));
return {
@@ -3547,19 +3605,10 @@ describe("HostRuntimeStore", () => {
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
@@ -3568,27 +3617,14 @@ describe("HostRuntimeStore", () => {
});
it("stores relay TLS from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const store = createPairingStore();
answerHostConfirmations(store, true);
await store.upsertConnectionFromOffer(
makeOffer({
relay: {
endpoint: "relay.example.com:443",
useTls: true,
},
}),
"tls relay",
);
await store
.beginLinkPairing()
.submit(
encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })),
);
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
@@ -3605,17 +3641,8 @@ describe("HostRuntimeStore", () => {
});
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const store = createPairingStore();
answerHostConfirmations(store, true);
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
@@ -3623,7 +3650,7 @@ describe("HostRuntimeStore", () => {
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
await store.importConnectionLink(oldPairingUrl, "openProject");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
@@ -3640,54 +3667,266 @@ describe("HostRuntimeStore", () => {
});
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 store = createPairingStore({ password: "correct-password" });
answerHostConfirmations(store, true);
const offerUrl = encodeOfferUrl(makeOffer());
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
"Password required",
);
const pairing = store.beginLinkPairing();
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
expect(store.getHosts()).toHaveLength(0);
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
expect(result.serverId).toBe("srv_offer");
const result = await pairing.submit(offerUrl, "correct-password");
expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" });
expect(store.getHosts()[0]?.password).toBe("correct-password");
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => {
const connectAttempts: string[] = [];
const store = createPairingStore({ connectAttempts });
const requests = answerHostConfirmations(store, false);
const offerUrl = encodeOfferUrl(makeOffer());
const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true";
// `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals.
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
status: "cancelled",
});
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
status: "cancelled",
});
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({
status: "cancelled",
});
await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({
status: "cancelled",
});
expect(requests).toEqual([
...Array.from({ length: 3 }, () => ({
id: expect.any(Number),
serverId: "srv_offer",
keyFingerprint: "pkte stof fer",
relayEndpoint: "relay.paseo.sh:443",
kind: "newHost",
})),
{
id: expect.any(Number),
serverId: "srv_relay",
keyFingerprint: "AAAA",
relayEndpoint: "relay.example:443",
kind: "newHost",
},
storage: createMemoryHostRuntimeStorage(),
});
]);
expect(store.getHosts()).toEqual([]);
expect(connectAttempts).toEqual([]);
store.syncHosts([]);
});
await store.upsertRelayConnection({
it("connects a saved host from a matching pairing link without asking", async () => {
const store = createPairingStore();
const requests = answerHostConfirmations(store, true);
const offerUrl = encodeOfferUrl(makeOffer());
await store.beginLinkPairing().submit(offerUrl);
expect(requests).toHaveLength(1);
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
status: "connected",
serverId: "srv_offer",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test_offer",
label: "Custom name",
});
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
status: "connected",
serverId: "srv_offer",
});
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({
status: "connected",
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
expect(requests).toHaveLength(1);
expect(store.getHosts()).toHaveLength(1);
store.syncHosts([]);
});
it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => {
const store = createPairingStore();
const requests = answerHostConfirmations(store, (_request, index) => index === 0);
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
const savedHosts = store.getHosts();
const changedLinks = [
makeOffer({ daemonPublicKeyB64: "pk_other_key" }),
makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }),
makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }),
].map(encodeOfferUrl);
for (const link of changedLinks) {
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
status: "cancelled",
});
await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({
status: "cancelled",
});
}
expect(requests.map((request) => request.kind)).toEqual([
"newHost",
...Array.from({ length: 6 }, () => "changedConnection"),
]);
expect(store.getHosts()).toEqual(savedHosts);
store.syncHosts([]);
});
it("asks once per pairing flow and link when the host asks for a password", async () => {
const store = createPairingStore({ password: "correct-password" });
const requests = answerHostConfirmations(store, (_request, index) => index < 2);
const offerUrl = encodeOfferUrl(makeOffer());
const pairing = store.beginLinkPairing();
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow();
expect(requests).toHaveLength(1);
// A new pairing flow for the same link asks again.
await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow();
expect(requests).toHaveLength(2);
await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({
status: "connected",
});
expect(requests).toHaveLength(2);
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_offer",
password: "correct-password",
});
// A different link in the same flow asks again.
const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({
status: "cancelled",
});
expect(requests).toHaveLength(3);
store.syncHosts([]);
});
it("continues a scanned link's password retry without asking again", async () => {
const store = createPairingStore({ password: "correct-password" });
const requests = answerHostConfirmations(store, true);
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
const outcome = await store.importConnectionLink(link, "hostRoot");
expect(outcome).toMatchObject({ status: "password_required", link });
expect(store.getHosts()).toHaveLength(0);
if (outcome.status !== "password_required") throw new Error("expected password_required");
await outcome.pairing.submit(outcome.link, "correct-password");
expect(requests).toHaveLength(1);
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_pair",
password: "correct-password",
});
store.syncHosts([]);
});
it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => {
const store = createPairingStore();
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
await vi.waitFor(() =>
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
);
const second = store.importConnectionLink(
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
"openProject",
);
await expect(first).resolves.toEqual({ status: "cancelled" });
await vi.waitFor(() =>
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }),
);
store.answerHostConfirmation(requirePendingConfirmation(store).id, true);
await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" });
expect(store.getPendingHostConfirmation()).toBeNull();
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]);
store.syncHosts([]);
});
it("ignores an answer for a host confirmation that a newer one replaced", async () => {
const store = createPairingStore();
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
await vi.waitFor(() =>
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
);
const firstRequest = requirePendingConfirmation(store);
const second = store.importConnectionLink(
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
"openProject",
);
await expect(first).resolves.toEqual({ status: "cancelled" });
// Connect tapped on the first sheet as the second link arrives.
store.answerHostConfirmation(firstRequest.id, true);
const secondRequest = requirePendingConfirmation(store);
expect(secondRequest.serverId).toBe("srv_second");
store.answerHostConfirmation(secondRequest.id, false);
await expect(second).resolves.toEqual({ status: "cancelled" });
expect(store.getHosts()).toEqual([]);
});
it("waits for saved hosts to load before deciding whether a pairing link asks", async () => {
const savedHost = makeHost({
serverId: "srv_offer",
connections: [
{
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: false,
daemonPublicKeyB64: "pk_test_offer",
},
],
});
const store = createPairingStore({
storage: createMemoryHostRuntimeStorage({
"@paseo:daemon-registry": JSON.stringify([savedHost]),
"@paseo:e2e": "1",
}),
});
const requests = answerHostConfirmations(store, true);
await expect(
store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"),
).resolves.toEqual({ status: "connected", serverId: "srv_offer" });
expect(requests).toEqual([]);
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]);
store.syncHosts([]);
});
it("adds hosts by address and the default localhost without asking", async () => {
const store = createPairingStore({
serverIdForHost: (host) =>
host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "",
});
const requests = answerHostConfirmations(store, false);
await store.boot();
await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" });
expect(requests).toEqual([]);
expect(store.getHosts().map((host) => host.serverId)).toEqual([
"srv_localhost:6767",
"srv_10.0.0.5:6767",
]);
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
await store.renameHost("srv_offer", "Custom name");
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("Custom name");
@@ -3963,35 +4202,6 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
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(),
@@ -4003,6 +4213,7 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
getClientId: async () => "cid_deep_link",
},
});
answerHostConfirmations(store, true);
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
status: "connected",
+138 -76
View File
@@ -14,6 +14,7 @@ import {
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasConnection,
relayConnectionFromOffer,
StoredHostRegistrySchema,
type HostConnection,
type HostProfile,
@@ -29,6 +30,7 @@ import {
} from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform";
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
@@ -89,6 +91,27 @@ export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "of
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
export type HostRegistryStatus = "loading" | "ready";
/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */
export interface LinkPairing {
/** Probes the linked host and saves it. Connection and password errors throw. */
submit(link: string, password?: string): Promise<LinkPairingResult>;
}
export type LinkPairingResult =
| { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null }
| { status: "cancelled" };
/** A pairing link the user confirmed whose host then asked for a password. */
export interface PasswordRequiredPairing {
link: string;
pairing: LinkPairing;
}
export type ConnectionLinkImport =
| { status: "connected"; serverId: string }
| ({ status: "password_required" } & PasswordRequiredPairing)
| { status: "cancelled" };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1415,6 +1438,13 @@ function readConfiguredLocalDaemonOverride(): string | null {
return value && value.length > 0 ? value : null;
}
function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean {
return (
left.serverId === right.serverId &&
equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right))
);
}
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
return parseRelayConnectionUri(input);
@@ -1476,6 +1506,8 @@ export class HostRuntimeStore {
private timelineReplicaByServer = new Map<string, TimelineReplica>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootPromise: Promise<void> | null = null;
private registryLoad: Promise<void> | null = null;
private readonly hostConfirmations = new HostConfirmations();
private storage: HostRuntimeStorage;
private replicaCache: ReplicaCache;
private readonly revokePushNotifications: typeof revokePushNotifications;
@@ -1526,8 +1558,7 @@ export class HostRuntimeStore {
private async runBoot(): Promise<void> {
const override = readConfiguredLocalDaemonOverride();
await this.loadFromStorage();
this.markHostRegistryLoaded();
await this.loadRegistry();
let isE2E: string | null = null;
try {
@@ -1560,6 +1591,13 @@ export class HostRuntimeStore {
}
}
private loadRegistry(): Promise<void> {
if (!this.registryLoad) {
this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded());
}
return this.registryLoad;
}
private async loadFromStorage(): Promise<void> {
let shouldPersistHosts = false;
let profiles: HostProfile[] = [];
@@ -1886,7 +1924,7 @@ export class HostRuntimeStore {
});
}
async upsertRelayConnection(input: {
private async upsertRelayConnection(input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
@@ -1919,51 +1957,91 @@ export class HostRuntimeStore {
return profile;
}
async upsertConnectionFromOffer(
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
// confirm when it adds a new host or changes a saved host's key, relay, or
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
await this.loadRegistry();
return this.hostConfirmations.confirmLink(offer, this.hosts);
}
getPendingHostConfirmation(): HostConfirmationRequest | null {
return this.hostConfirmations.getPending();
}
subscribeHostConfirmation(listener: () => void): () => void {
return this.hostConfirmations.subscribe(listener);
}
answerHostConfirmation(requestId: number, approved: boolean): void {
this.hostConfirmations.answer(requestId, approved);
}
/**
* Starts one pairing flow, such as one open pairing modal. Once the user
* confirms a link, submitting the same link again in this flow (the
* password retry) does not ask again. A new flow asks again.
*/
beginLinkPairing(): LinkPairing {
let approvedOffer: ConnectionOffer | null = null;
return {
submit: async (link, password) => {
const { offer, password: linkPassword } = parseOfferConnectionUrl(link);
if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) {
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
approvedOffer = offer;
}
const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword);
return { status: "connected", ...result };
},
};
}
async importConnectionLink(
url: string,
target: PairingNavigationTarget,
): Promise<ConnectionLinkImport> {
if (target === "openProject") {
const { offer, password } = parseOfferConnectionUrl(url);
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
const profile = await this.upsertConnectionFromOffer(offer, undefined, password);
return { status: "connected", serverId: profile.serverId };
}
const pairing = this.beginLinkPairing();
try {
const result = await pairing.submit(url);
if (result.status === "cancelled") return result;
return { status: "connected", serverId: result.serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
return { status: "password_required", link: url, pairing };
}
}
private async upsertConnectionFromOffer(
offer: ConnectionOffer,
label?: string,
password?: string,
): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const connection = relayConnectionFromOffer(offer);
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
relayEndpoint: connection.relayEndpoint,
useTls: connection.useTls,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
label,
password,
});
}
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
password?: string,
): Promise<HostProfile> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
}
async probeAndUpsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
password?: string,
private async probeAndUpsertOffer(
offer: ConnectionOffer,
password: string | undefined,
): 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 connection = relayConnectionFromOffer(offer);
const probeHost: HostProfile = {
serverId: offer.serverId,
...(credential ? { password: credential } : {}),
...(password ? { password } : {}),
label: offer.serverId,
appearance: defaultHostAppearance(),
lifecycle: {},
@@ -1974,27 +2052,10 @@ export class HostRuntimeStore {
};
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
await client.close().catch(() => undefined);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
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 };
}
try {
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
return { status: "connected", serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
return { status: "password_required" };
}
}
async upsertConnectionFromListen(input: {
listenAddress: string;
serverId: string;
@@ -2742,6 +2803,29 @@ export function useHostRegistryLoaded(): boolean {
);
}
export function useHostConfirmation(): {
pending: HostConfirmationRequest | null;
answer: (approved: boolean) => void;
} {
const store = getHostRuntimeStore();
const pending = useSyncExternalStore(
(onStoreChange) => store.subscribeHostConfirmation(onStoreChange),
() => store.getPendingHostConfirmation(),
() => store.getPendingHostConfirmation(),
);
// The answer is bound to the request this render shows, so a tap never
// answers a request that replaced it.
return useMemo(
() => ({
pending,
answer: (approved: boolean) => {
if (pending) store.answerHostConfirmation(pending.id, approved);
},
}),
[pending, store],
);
}
export interface HostMutations {
upsertDirectConnection: (input: {
serverId: string;
@@ -2762,24 +2846,7 @@ export interface HostMutations {
daemonPort?: number;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
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 }>;
beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
@@ -2794,12 +2861,7 @@ export function useHostMutations(): HostMutations {
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label, password) =>
store.upsertConnectionFromOfferUrl(url, label, password),
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
store.probeAndUpsertConnectionFromOfferUrl(url, password),
beginLinkPairing: () => store.beginLinkPairing(),
renameHost: (serverId, label) => store.renameHost(serverId, label),
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
setHostBadgeDisplay: (serverId, badgeDisplay) =>
@@ -122,6 +122,8 @@ import {
} from "./new-workspace-initial-context";
import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
import { ImportSessionButton } from "./new-workspace/import-session-button";
import { useImportSession } from "@/hooks/use-import-session";
import {
buildTerminalsQueryKey,
type ListTerminalsPayload,
@@ -2367,6 +2369,7 @@ export function NewWorkspaceScreen({
});
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
const importSession = useImportSession({ serverId: selectedServerId });
const composer = isTerminalLaunch ? (
<Composer
@@ -2435,11 +2438,13 @@ export function NewWorkspaceScreen({
isCompact={isCompact}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
</View>
{importSession.sheet}
</FileDropZone>
);
}
@@ -2448,15 +2453,20 @@ function NewWorkspaceLayout({
isCompact,
title,
formStack,
onImportSession,
children,
}: {
isCompact: boolean;
title: string;
formStack: ReactNode;
onImportSession: () => void;
children: ReactNode;
}) {
// At the top of the screen on compact layouts, under the composer otherwise.
const importSessionButton = <ImportSessionButton compact={isCompact} onPress={onImportSession} />;
const setupFields = (
<>
{isCompact ? <View style={styles.compactTopActions}>{importSessionButton}</View> : null}
<View style={styles.composerTitleContainer} pointerEvents="none">
<Text style={styles.composerTitle}>{title}</Text>
</View>
@@ -2466,7 +2476,10 @@ function NewWorkspaceLayout({
return (
<ComposerDock centered={!isCompact}>
{setupFields}
{children}
<>
{children}
{isCompact ? null : importSessionButton}
</>
</ComposerDock>
);
}
@@ -2481,6 +2494,12 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
flex: 1,
},
// Takes the free space above the setup fields, so its button sits at the top of the screen.
// The inset puts the ghost button's icon on the setup rows' icon rail.
compactTopActions: {
flex: 1,
paddingHorizontal: theme.spacing[3],
},
composerTitleContainer: {
marginBottom: theme.spacing[8],
paddingLeft: theme.spacing[6],
@@ -2503,7 +2522,8 @@ const styles = StyleSheet.create((theme) => ({
formStackDesktop: {
flexDirection: "row",
alignItems: "center",
marginBottom: theme.spacing[8],
// Matches the gap between the composer and the Import session pill below it.
marginBottom: theme.spacing[4],
// The badge adds its own left padding; offset it so the project icon's left
// edge lands exactly on the "New workspace" title's left edge. The trailing
// inset mirrors it so the launch chip stops on the composer's inner content
@@ -0,0 +1,50 @@
import { Import } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
/**
* The new workspace screen's way into Import session: a ghost button at the top on compact
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
*/
export function ImportSessionButton({
compact,
onPress,
}: {
compact: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
return (
<View style={compact ? styles.compact : styles.wide}>
<Button
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
variant="ghost"
size="sm"
leftIcon={Import}
onPress={onPress}
style={compact ? null : styles.pill}
testID="new-workspace-import-session"
>
{t("importSession.title")}
</Button>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
compact: {
alignItems: "flex-start",
},
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
// border lines up with the composer's border.
wide: {
alignItems: "flex-start",
paddingHorizontal: theme.spacing[4],
},
pill: {
borderRadius: theme.borderRadius.full,
borderColor: theme.colors.borderAccent,
},
}));
@@ -179,7 +179,7 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen
);
}
/** Settings > Sidebar: one card per section. The footer's Add project row and icon row are fixed and not listed. */
/** Settings > Sidebar: one card per section. The footer's bottom line is fixed and not listed. */
export function SidebarNavSection(): ReactElement {
return (
<>
+41 -2
View File
@@ -34,6 +34,7 @@ describe("resolveSidebarNavItems", () => {
it("yields builtins then plugins, all visible, when nothing is stored", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [kanban, notes],
preferences: [],
});
@@ -58,6 +59,7 @@ describe("resolveSidebarNavItems", () => {
it("keeps the stored order and appends newly available items as visible", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [notes, kanban],
preferences: [
{ key: kanbanKey, visible: false },
@@ -79,6 +81,7 @@ describe("resolveSidebarNavItems", () => {
it("skips keys that are unknown or not currently available", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: [
{ key: notesKey, visible: false },
@@ -98,6 +101,7 @@ describe("resolveSidebarNavItems", () => {
it("lets the first of duplicate keys win", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: [
{ key: "history", visible: false },
@@ -114,10 +118,26 @@ describe("resolveSidebarNavItems", () => {
});
});
describe("resolveSidebarNavItems on compact layouts", () => {
it("hides the Usage item until it is turned on", () => {
const resolve = (compact: boolean, preferences: SidebarNavPreference[]) =>
summarize(
resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }),
);
expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]);
expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]);
expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([
{ key: "usage", visible: true },
]);
});
});
describe("setSidebarNavItemVisible", () => {
it("toggles one item and writes the full resolved order", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [kanban],
preferences: [],
});
@@ -140,6 +160,7 @@ describe("setSidebarNavItemVisible", () => {
];
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: previous,
});
@@ -165,6 +186,7 @@ describe("setSidebarNavItemVisible", () => {
];
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: previous,
});
@@ -180,13 +202,23 @@ describe("setSidebarNavItemVisible", () => {
]);
expect(
summarize(
resolveSidebarNavItems({ section: "header", pluginGroups: [notes], preferences: next }),
resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [notes],
preferences: next,
}),
),
).toEqual(next);
});
it("returns the normalized list unchanged for an unknown key", () => {
const items = resolveSidebarNavItems({ section: "header", pluginGroups: [], preferences: [] });
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: [],
});
const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] });
@@ -197,6 +229,7 @@ describe("setSidebarNavItemVisible", () => {
describe("moveSidebarNavItem", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [kanban],
preferences: [],
});
@@ -274,6 +307,7 @@ describe("footer section", () => {
it("resolves the Usage item first, then plugin rows", () => {
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: [],
});
@@ -287,6 +321,7 @@ describe("footer section", () => {
it("ignores header built-ins stored in footer preferences", () => {
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [],
preferences: [{ key: "history", visible: false }],
});
@@ -297,8 +332,10 @@ describe("footer section", () => {
it("ignores the footer icon buttons, which are fixed and not items", () => {
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: [
{ key: "add-project", visible: false },
{ key: "hosts", visible: false },
{ key: "import", visible: false },
{ key: "help", visible: false },
@@ -318,6 +355,7 @@ describe("footer section", () => {
const previous: SidebarNavPreference[] = [notesPreference];
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: previous,
});
@@ -326,6 +364,7 @@ describe("footer section", () => {
const hidden = setSidebarNavItemVisible({
items: resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: moved,
}),
+20 -3
View File
@@ -4,8 +4,8 @@ import type { PluginSidebarSection } from "@/plugins/types";
export type SidebarSection = PluginSidebarSection;
/**
* Each section's built-in items in their default order. The footer's Add project row and its
* icon row (Hosts, Import session, Help and support, Settings) are fixed and not items.
* Each section's built-in items in their default order. The footer's bottom line
* (Add project and the Hosts, Help and support, Settings icons) is fixed.
*/
export const BUILTIN_SIDEBAR_ITEM_IDS = {
header: ["new-workspace", "history", "search", "schedules"],
@@ -69,6 +69,16 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin
return BUILTIN_SHORTCUT_ACTIONS[id];
}
/**
* Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer
* has no room to spare for the Usage summary.
*/
const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet<BuiltinSidebarItemId> = new Set(["usage"]);
function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean {
return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id));
}
export function pluginSidebarNavKey(
group: Pick<PluginSidebarGroup, "pluginId" | "contributionId">,
): string {
@@ -85,6 +95,8 @@ function isBuiltinSidebarItemId<Section extends SidebarSection>(
export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
section: Section;
/** Compact layouts start some builtins hidden; a stored preference always wins. */
compact: boolean;
pluginGroups: readonly PluginSidebarGroup[];
preferences: readonly SidebarNavPreference[];
}): SidebarNavItem<Section>[] {
@@ -115,7 +127,12 @@ export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
for (const id of builtinIds) {
if (placed.has(id)) continue;
items.push({ kind: "builtin", key: id, id, visible: true });
items.push({
kind: "builtin",
key: id,
id,
visible: builtinVisibleByDefault(id, input.compact),
});
}
for (const [key, group] of groupsByKey) {
if (placed.has(key)) continue;
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useAppSettings } from "@/hooks/use-settings";
import type { AppSettings } from "@/hooks/use-settings/storage";
import { useInstalledPlugins } from "@/plugins/registry";
@@ -27,14 +28,15 @@ export function useSidebarNavItems<Section extends SidebarSection>(
section: Section,
): UseSidebarNavItemsReturn<Section> {
const plugins = useInstalledPlugins();
const compact = useIsCompactFormFactor();
const { settings, updateSettings } = useAppSettings();
const field = PREFERENCE_FIELDS[section];
const preferences = settings[field];
const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]);
const items = useMemo(
() => resolveSidebarNavItems({ section, pluginGroups, preferences }),
[pluginGroups, preferences, section],
() => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }),
[compact, pluginGroups, preferences, section],
);
const setVisible = useCallback(
@@ -43,6 +45,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
const previous = current[field];
const currentItems = resolveSidebarNavItems({
section,
compact,
pluginGroups,
preferences: previous,
});
@@ -51,7 +54,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
};
});
},
[field, pluginGroups, section, updateSettings],
[compact, field, pluginGroups, section, updateSettings],
);
const move = useCallback(
@@ -60,6 +63,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
const previous = current[field];
const currentItems = resolveSidebarNavItems({
section,
compact,
pluginGroups,
preferences: previous,
});
@@ -68,7 +72,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
};
});
},
[field, pluginGroups, section, updateSettings],
[compact, field, pluginGroups, section, updateSettings],
);
return { items, setVisible, move };
@@ -1,10 +1,6 @@
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
type BottomSheetScrollViewMethods,
} from "@gorhom/bottom-sheet";
import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet";
import { Wrench, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -38,12 +34,7 @@ export function OverviewToolCallGroupSheet({
visible,
onClose,
});
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[],
);
const scrollToLatest = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
}, []);
@@ -64,7 +55,7 @@ export function OverviewToolCallGroupSheet({
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
backdropOpacity={0.5}
enablePanDownToClose
>
<View style={styles.container} testID="tool-call-group-sheet">
+15
View File
@@ -1,6 +1,8 @@
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import {
normalizeHostPort,
normalizeLoopbackToLocalhost,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import {
DirectTcpHostConnectionSchema,
@@ -354,6 +356,19 @@ export function connectionFromListen(listen: string): HostConnection | null {
}
}
export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConnection {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
return {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(),
};
}
export function createRemoteSshHostConnection(input: {
host: string;
sshPort?: number;
+9 -6
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
@@ -9,19 +10,20 @@ interface ResolvedBalance {
usedPct: number | null;
}
function resolveBalance(balance: UsageBalance): ResolvedBalance {
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
const format = (value: number) => formatAmount(value, unit, locale);
if (limit != null && limit > 0) {
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
const usedText = usedAmount != null ? format(usedAmount) : "—";
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
}
if (remaining != null) {
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
return { amountText: `${format(remaining)} left`, usedPct: null };
}
if (used != null) {
return { amountText: formatAmount(used, unit), usedPct: null };
return { amountText: format(used), usedPct: null };
}
return { amountText: "—", usedPct: null };
}
@@ -40,7 +42,8 @@ function fillToneStyle(tone: UsageTone) {
}
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const { i18n } = useTranslation();
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt);
+17 -41
View File
@@ -1,22 +1,13 @@
import { RotateCw } from "lucide-react-native";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HostSwitcher } from "@/components/hosts/host-switcher";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import { HostFilter } from "@/components/hosts/host-filter";
import { useMemo, type ReactElement } from "react";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { UsageDisplayToggle } from "./display-toggle";
import { UsageOptionsMenu } from "./options-menu";
import type { UsageHost } from "./model";
import { useHostUsage } from "./queries";
import type { UsageView } from "./types";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
/** The hosts to choose between, and which one is shown. */
export interface UsageHostSelection {
hosts: UsageHost[];
@@ -25,9 +16,8 @@ export interface UsageHostSelection {
}
/**
* The controls on the right of every usage title row: the host selector, which also names the
* host shown, the used/remaining toggle and Refresh. A host that cannot report usage keeps only
* the selector.
* The controls on the right of every usage title row: the host filter when there is more than one
* host, and the options menu. A host that cannot report usage keeps only the host filter.
*/
export function UsageControls({
view,
@@ -41,43 +31,29 @@ export function UsageControls({
hostSelection?: UsageHostSelection;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
<View style={styles.controls}>
{hostSelection && hostSelection.hosts.length > 0 ? (
<HostSwitcher
{hostSelection && hostSelection.hosts.length > 1 ? (
<HostFilter
hosts={hostSelection.hosts}
value={hostSelection.serverId}
onSelect={hostSelection.onSelect}
title={usageCopy.host}
accessibilityLabel={usageCopy.host}
testID="usage-host-switcher"
selectedHost={hostSelection.serverId}
onSelectHost={hostSelection.onSelect}
includeAllHost={false}
triggerTestID="usage-host-filter-trigger"
hostOptionTestID={usageHostOptionTestID}
/>
) : null}
{view.kind === "unavailable" ? null : (
<>
<UsageDisplayToggle display={display} />
{/* The Changes panel's refresh: an icon, with the label in its tooltip. */}
<ToolbarButton
label={busy ? usageCopy.refreshing : usageCopy.refresh}
compact={compact}
disabled={busy}
onPress={onRefresh}
testID="usage-refresh-all"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
</>
<UsageOptionsMenu display={display} busy={busy} onRefresh={onRefresh} />
)}
</View>
);
}
function usageHostOptionTestID(serverId: string): string {
return `usage-host-filter-item-${serverId}`;
}
const styles = StyleSheet.create((theme) => ({
controls: {
flexShrink: 1,
+3 -1
View File
@@ -3,6 +3,7 @@
export const usageCopy = {
title: "Usage",
planUsage: "Plan usage",
options: "Usage options",
refresh: "Refresh",
refreshing: "Refreshing...",
refreshFailed: "Unable to refresh usage",
@@ -16,7 +17,8 @@ export const usageCopy = {
clientUnavailable: "Host connection is not ready",
retry: "Try again",
pin: "Pin",
host: "Usage host",
displayAs: "Show",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Show in sidebar",
} as const;
-22
View File
@@ -1,22 +0,0 @@
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import type { UsageDisplayAs } from "./preferences";
const OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
/** Switches every usage surface between the share used and the share left. */
export function UsageDisplayToggle({ display }: { display: UsageDisplay }) {
return (
<SegmentedControl
size="xs"
options={OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
testID="usage-display-toggle"
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { formatAmount } from "./format";
describe("formatAmount", () => {
it("groups thousands in the app's language", () => {
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345");
});
it("formats dollars as the language writes currency", () => {
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
});
});
+4 -3
View File
@@ -34,13 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
export function formatAmount(value: number, unit: UsageBalanceUnit): string {
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
switch (unit) {
case "usd":
return `$${value.toFixed(2)}`;
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
case "tokens":
return formatTokenCount(value);
default:
return value.toLocaleString();
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
}
}
+34 -14
View File
@@ -1,9 +1,16 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import {
useActiveWorkspaceSelection,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { resolveUsageHostId, resolveUsageScreenHostId, type UsageHost } from "./model";
import {
resolveUsageHostId,
resolveUsageScreenHostId,
type UsageHost,
type UsageHostChoice,
} from "./model";
import { setUsageHost } from "./preferences";
import { useUsageHosts } from "./queries";
/** The active workspace's host; off a workspace route, the last workspace visited. */
@@ -13,27 +20,40 @@ function useActiveServerId(): string | null {
return active?.serverId ?? last?.serverId ?? null;
}
function useUsageHostChoice(): UsageHostChoice {
const { settings } = useAppSettings();
return {
pickedServerId: settings.usage.serverId,
activeServerId: useActiveServerId(),
hosts: useUsageHosts(),
};
}
/** The host the sidebar Usage row reads, or null when none reports usage. */
export function useUsageHostId(): string | null {
return resolveUsageHostId(useActiveServerId(), useUsageHosts());
return resolveUsageHostId(useUsageHostChoice());
}
/**
* The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. The pick
* is the view's state: it resets when the view unmounts.
* The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick
* is saved on the device, so the sidebar Usage row and later visits show the same host.
*/
export function useUsageHostSelection(): {
serverId: string | null;
connectedHosts: UsageHost[];
select: (serverId: string) => void;
} {
const hosts = useUsageHosts();
const activeServerId = useActiveServerId();
const [selectedServerId, select] = useState<string | null>(null);
const connectedHosts = useMemo(() => hosts.filter((host) => host.isConnected), [hosts]);
return {
serverId: resolveUsageScreenHostId({ selectedServerId, activeServerId, hosts }),
connectedHosts,
select,
};
const { updateSettings } = useAppSettings();
const choice = useUsageHostChoice();
const connectedHosts = useMemo(
() => choice.hosts.filter((host) => host.isConnected),
[choice.hosts],
);
const select = useCallback(
(serverId: string) => {
void updateSettings((current) => ({ usage: setUsageHost(current.usage, serverId) }));
},
[updateSettings],
);
return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select };
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { HostUsageSection } from "./host-usage-section";
export { UsageScreen } from "./usage-screen";
export { UsageSidebarItem } from "./sidebar-item";
export { UsageSidebarItem, useOpenUsageScreen } from "./sidebar-item";
+74
View File
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct } from "./format";
import type { UsageTone } from "./types";
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
/**
* A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead,
* for summaries where a row of coloured bars would be noise. The caller sizes the track.
*/
export function UsageMeter({
percent,
tone,
quiet = false,
style,
}: {
percent: number;
tone: UsageTone;
quiet?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const fillWidth = clampPct(percent);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }],
[fillWidth, quiet, tone],
);
return (
<View style={[styles.track, style]}>
<View style={fillStyle} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
// Follows the track, so a caller that sizes the track sizes the bar.
fill: {
height: "100%",
borderRadius: theme.borderRadius.full,
},
fillQuiet: {
backgroundColor: theme.colors.surface4,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
+32 -19
View File
@@ -84,52 +84,65 @@ const hosts: UsageHost[] = [
];
describe("resolveUsageHostId", () => {
it("reads the active workspace's host", () => {
expect(resolveUsageHostId("b", hosts)).toBe("b");
const choose = (pickedServerId: string | null, activeServerId: string | null) =>
resolveUsageHostId({ pickedServerId, activeServerId, hosts });
it("reads the picked host over the active workspace's host", () => {
expect(choose("a", "b")).toBe("a");
});
it("reads the active workspace's host when nothing usable is picked", () => {
expect(choose(null, "b")).toBe("b");
expect(choose("offline", "b")).toBe("b");
expect(choose("old", "b")).toBe("b");
});
it("falls back to the first connected host that reports usage", () => {
expect(resolveUsageHostId(null, hosts)).toBe("a");
expect(resolveUsageHostId("offline", hosts)).toBe("a");
expect(resolveUsageHostId("old", hosts)).toBe("a");
expect(choose(null, null)).toBe("a");
expect(choose(null, "offline")).toBe("a");
expect(choose(null, "old")).toBe("a");
});
it("has no host when none reports usage", () => {
expect(resolveUsageHostId("old", hosts.slice(0, 2))).toBeNull();
expect(
resolveUsageHostId({
pickedServerId: "old",
activeServerId: "old",
hosts: hosts.slice(0, 2),
}),
).toBeNull();
});
});
describe("resolveUsageScreenHostId", () => {
it("keeps the user's pick while it stays connected", () => {
expect(resolveUsageScreenHostId({ selectedServerId: "old", activeServerId: "b", hosts })).toBe(
it("shows the picked host while it stays connected, even one that cannot report usage", () => {
expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe(
"old",
);
expect(
resolveUsageScreenHostId({ selectedServerId: "offline", activeServerId: "b", hosts }),
resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }),
).toBe("b");
});
it("defaults to the host the sidebar row reads", () => {
for (const activeServerId of ["b", null, "old", "offline"]) {
expect(resolveUsageScreenHostId({ selectedServerId: null, activeServerId, hosts })).toBe(
resolveUsageHostId(activeServerId, hosts),
);
it("otherwise shows the host the sidebar row reads", () => {
for (const pickedServerId of [null, "a", "offline"]) {
for (const activeServerId of ["b", null, "old", "offline"]) {
const choice = { pickedServerId, activeServerId, hosts };
expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice));
}
}
expect(resolveUsageScreenHostId({ selectedServerId: null, activeServerId: "old", hosts })).toBe(
"a",
);
});
it("shows the first connected host when none reports usage, so the screen says to update it", () => {
expect(
resolveUsageScreenHostId({
selectedServerId: null,
pickedServerId: null,
activeServerId: null,
hosts: hosts.slice(0, 2),
}),
).toBe("old");
expect(
resolveUsageScreenHostId({ selectedServerId: null, activeServerId: null, hosts: [] }),
resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
).toBeNull();
});
});
+22 -24
View File
@@ -97,34 +97,32 @@ export interface UsageHost {
supportsUsage: boolean;
}
/**
* The host usage shows by default, on the sidebar row and the Usage screen: the active workspace's
* host if it reports usage, else the first host that does.
*/
export function resolveUsageHostId(
activeServerId: string | null,
hosts: readonly UsageHost[],
): string | null {
const reporting = hosts.filter((host) => host.isConnected && host.supportsUsage);
const active = reporting.find((host) => host.serverId === activeServerId);
return (active ?? reporting[0])?.serverId ?? null;
/** Where usage looks for its host: the user's saved pick, then the workspace they are in. */
export interface UsageHostChoice {
pickedServerId: string | null;
activeServerId: string | null;
hosts: readonly UsageHost[];
}
/**
* The host the Usage screen shows: the user's pick while it stays connected, else the default
* host. With no host reporting usage, the first connected host, so the screen says to update it.
* The host the sidebar Usage row reads: the picked host, else the active workspace's host, else the
* first host. Each only while it is connected and reports usage.
*/
export function resolveUsageScreenHostId(input: {
selectedServerId: string | null;
activeServerId: string | null;
hosts: readonly UsageHost[];
}): string | null {
const connected = input.hosts.filter((host) => host.isConnected);
const selected = connected.find((host) => host.serverId === input.selectedServerId);
export function resolveUsageHostId(choice: UsageHostChoice): string | null {
const reporting = choice.hosts.filter((host) => host.isConnected && host.supportsUsage);
const find = (serverId: string | null) => reporting.find((host) => host.serverId === serverId);
return (
selected?.serverId ??
resolveUsageHostId(input.activeServerId, input.hosts) ??
connected[0]?.serverId ??
null
(find(choice.pickedServerId) ?? find(choice.activeServerId) ?? reporting[0])?.serverId ?? null
);
}
/**
* The host the Usage screen shows: the picked host while it is connected, even one that cannot
* report usage so the screen says to update it; else the sidebar row's host; else the first
* connected host.
*/
export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null {
const connected = choice.hosts.filter((host) => host.isConnected);
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
}
+103
View File
@@ -0,0 +1,103 @@
import { Settings } from "lucide-react-native";
import { useCallback } from "react";
import { withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
const ThemedSettings = withUnistyles(Settings);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
/**
* The usage title row's options: Refresh every report, whether percents read as used or
* remaining, and whether the sidebar shows the Usage item (the same switch as Settings > Sidebar). The trigger spins while a refresh is in flight, since the menu hides the one it came
* from.
*/
export function UsageOptionsMenu({
display,
busy,
onRefresh,
}: {
display: UsageDisplay;
busy: boolean;
onRefresh: () => void;
}) {
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
const { displayAs, setDisplayAs } = display;
const showUsed = useCallback(() => setDisplayAs("used"), [setDisplayAs]);
const showRemaining = useCallback(() => setDisplayAs("remaining"), [setDisplayAs]);
const sidebarItems = useSidebarNavItems("footer");
const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible);
const { setVisible } = sidebarItems;
const toggleInSidebar = useCallback(
() => setVisible("usage", !inSidebar),
[inSidebar, setVisible],
);
return (
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
compact={compact}
testID="usage-options-menu"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedSettings size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
<DropdownMenuContent
align="end"
width={200}
sheetTitle={usageCopy.options}
testID="usage-options-menu-content"
>
<DropdownMenuItem
status={busy ? "pending" : "idle"}
pendingLabel={usageCopy.refreshing}
onSelect={onRefresh}
testID="usage-refresh-all"
>
{usageCopy.refresh}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{usageCopy.displayAs}</DropdownMenuLabel>
<DropdownMenuItem
selected={displayAs === "used"}
onSelect={showUsed}
testID="usage-display-used"
>
{usageCopy.displayUsed}
</DropdownMenuItem>
<DropdownMenuItem
selected={displayAs === "remaining"}
onSelect={showRemaining}
testID="usage-display-remaining"
>
{usageCopy.displayRemaining}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
selected={inSidebar}
onSelect={toggleInSidebar}
testID="usage-show-in-sidebar"
>
{usageCopy.showInSidebar}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+155 -21
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import type { UsagePreferences } from "./preferences";
import { resolvePinnedUsage } from "./pinned";
import {
choosePinnedUsageLayout,
resolvePinnedUsage,
type PinnedUsageSource,
type PinnedUsageWindow,
} from "./pinned";
import type { UsageReportEntry, UsageWindow } from "./types";
function report(input: {
@@ -26,8 +31,8 @@ const claude = report({
sourceLabel: "Claude",
icon: "<svg/>",
windows: [
{ id: "five-hour", label: "5-hour", usedPct: 31 },
{ id: "weekly", label: "Weekly", usedPct: 80 },
{ id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 },
{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 },
],
});
const codex = report({
@@ -36,36 +41,161 @@ const codex = report({
windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }],
});
function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] {
return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows);
}
function preferences(
pinned: UsagePreferences["pinned"],
displayAs: UsagePreferences["displayAs"] = "used",
): UsagePreferences {
return { displayAs, pinned };
return { displayAs, pinned, serverId: null };
}
describe("choosePinnedUsageLayout", () => {
const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 };
it("shows meters only when every window's meter fits beside the labels", () => {
expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters");
expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels");
});
it("drops the labels before it lets the line overflow", () => {
expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents");
});
it("shows labels until the widths are measured", () => {
expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels");
expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe(
"labels",
);
});
});
describe("resolvePinnedUsage", () => {
it("shows pinned windows in pin order, not report order", () => {
const items = resolvePinnedUsage(
[claude, codex],
it("defaults to the first window with a percent for each source account", () => {
const work = report({
sourceId: "claude",
sourceLabel: "Claude",
account: "work",
windows: [
{ id: "empty", label: "Empty" },
{ id: "weekly", label: "Weekly", usedPct: 90 },
],
});
const empty = report({
sourceId: "empty",
sourceLabel: "Empty",
windows: [{ id: "empty", label: "Empty" }],
});
expect(
windows([claude, codex, work, empty], preferences([])).map((item) => [
item.key,
item.percentText,
]),
).toEqual([
["claude:default/five-hour", "31%"],
["claude:work/weekly", "90%"],
["codex:default/weekly", "12%"],
]);
});
it("pins replace defaults, and removing all pins restores defaults", () => {
const reports = [claude, codex];
expect(
windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map(
(item) => item.key,
),
).toEqual(["claude:default/weekly"]);
expect(windows(reports, preferences([])).map((item) => item.key)).toEqual([
"claude:default/five-hour",
"codex:default/weekly",
]);
});
it("groups pinned windows by source in report order, not pin order", () => {
const work = report({
sourceId: "claude",
sourceLabel: "Claude",
account: "work",
windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }],
});
const sources = resolvePinnedUsage(
[claude, codex, work],
preferences([
{ sourceId: "claude", windowId: "weekly" },
{ sourceId: "codex", windowId: "weekly" },
{ sourceId: "claude", windowId: "five-hour" },
]),
);
expect(items).toEqual([
{ key: "codex:default/weekly", icon: null, label: "Codex Weekly", percentText: "12%" },
{
key: "claude:default/five-hour",
icon: "<svg/>",
label: "Claude 5-hour",
percentText: "31%",
},
const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key);
expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([
["claude:default", ["claude:default/five-hour", "claude:default/weekly"]],
["claude:work", ["claude:work/weekly"]],
["codex:default", ["codex:default/weekly"]],
]);
expect(sources[0]).toEqual({
key: "claude:default",
icon: "<svg/>",
windows: [
{
key: "claude:default/five-hour",
label: "Claude Session 31% used",
shortLabel: "5h",
percent: 31,
percentText: "31%",
tone: "default",
},
{
key: "claude:default/weekly",
label: "Claude Weekly 80% used",
shortLabel: "wk",
percent: 80,
percentText: "80%",
tone: "warning",
},
],
});
});
it("names a window by its label when the source gives no short label", () => {
const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }]));
expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]);
});
it("defaults to every window the source marks as summary, when it marks any", () => {
const marked = report({
sourceId: "claude",
sourceLabel: "Claude",
windows: [
{ id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true },
{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true },
{ id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 },
],
});
expect(windows([marked, codex], preferences([])).map((item) => item.key)).toEqual([
"claude:default/five-hour",
"claude:default/weekly",
"codex:default/weekly",
]);
});
it("keeps an empty short label empty, for a percent that needs no name", () => {
const opencode = report({
sourceId: "opencode",
sourceLabel: "OpenCode Go",
windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }],
});
const items = windows([opencode], preferences([]));
expect(items.map((item) => item.shortLabel)).toEqual([""]);
});
it("formats the share left when the user reads usage as remaining", () => {
const items = resolvePinnedUsage(
const items = windows(
[claude, codex],
preferences(
[
@@ -76,7 +206,11 @@ describe("resolvePinnedUsage", () => {
),
);
expect(items.map((item) => item.percentText)).toEqual(["69% left", "88% left"]);
expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]);
expect(items.map((item) => item.label)).toEqual([
"Claude Session 69% left",
"Codex Weekly 88% left",
]);
});
it("leaves out a pinned window that no report has, or that reports no percent", () => {
@@ -85,7 +219,7 @@ describe("resolvePinnedUsage", () => {
sourceLabel: "OpenCode",
windows: [{ id: "monthly", label: "Monthly" }],
});
const items = resolvePinnedUsage(
const items = windows(
[claude, noPercent],
preferences([
{ sourceId: "claude", windowId: "monthly" },
@@ -110,14 +244,14 @@ describe("resolvePinnedUsage", () => {
account: "work",
windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }],
});
const items = resolvePinnedUsage(
const items = windows(
[personal, codex, work],
preferences([{ sourceId: "claude", windowId: "five-hour" }]),
);
expect(items.map((item) => [item.label, item.percentText])).toEqual([
["Claude (personal) 5-hour", "10%"],
["Claude (work) 5-hour", "90%"],
["Claude (personal) 5-hour 10% used", "10%"],
["Claude (work) 5-hour 90% used", "90%"],
]);
});
});
+86 -24
View File
@@ -1,15 +1,53 @@
import { formatDisplayPct } from "./format";
import { formatPct } from "./format";
import { displayPercent } from "./model";
import type { UsagePreferences } from "./preferences";
import type { UsageReportEntry } from "./types";
import { windowTone } from "./tone";
import type { UsageReportEntry, UsageTone, UsageWindow } from "./types";
/** One pinned window of one account, as the sidebar Usage item shows it. */
/** One summary window of one account, as the sidebar Usage item shows it. */
export interface PinnedUsageWindow {
key: string;
icon: string | null;
/** Source, account when it has one, and window: "Claude (work) 5-hour". */
/** Accessible description, including the source, account, window and percent meaning. */
label: string;
/** The window's short name ("5h"), empty for none, or its label when the source sends none. */
shortLabel: string;
/** The share shown, under the user's used/remaining preference. */
percent: number;
percentText: string;
tone: UsageTone;
}
/** One account's summary windows, under its source's icon. */
export interface PinnedUsageSource {
key: string;
icon: string | null;
windows: PinnedUsageWindow[];
}
/** How much of each window the one-line summary has room for. */
export type PinnedUsageLayout = "meters" | "labels" | "percents";
// A meter narrower than this is a sliver that reads as noise rather than a share.
export const MIN_METER_WIDTH = 16;
/** The space between a meter and its text. */
export const METER_GAP = 4;
/**
* The richest layout that fits on one line: every window's meter, else its short label, else its
* percent alone. Meters are all or none; a line never mixes them. Until the widths are known it
* shows labels, which never flashes meters that turn out not to fit.
*/
export function choosePinnedUsageLayout(input: {
available: number | null;
labelsWidth: number | null;
percentsWidth: number | null;
windowCount: number;
}): PinnedUsageLayout {
const { available, labelsWidth, percentsWidth, windowCount } = input;
if (available === null || labelsWidth === null || percentsWidth === null) return "labels";
if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters";
if (labelsWidth <= available) return "labels";
return percentsWidth < labelsWidth ? "percents" : "labels";
}
function describe(entry: UsageReportEntry, windowLabel: string): string {
@@ -17,28 +55,52 @@ function describe(entry: UsageReportEntry, windowLabel: string): string {
return `${entry.sourceLabel}${account} ${windowLabel}`;
}
/** Reports with every account of a source together, sources in the order they first appear. */
function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] {
const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))];
return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId));
}
/**
* The pinned windows the Usage item shows: pins in pin order, and within a pin one item per account of the source
* in report order. A pinned window a report lacks, or reports no percent for, is left out.
* Pins replace defaults. Without pins, an account shows the windows its source marks as summary,
* or its first window with a percent when the source marks none.
*/
function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] {
const withPercent = entry.report.windows.filter(
(window) => displayPercent(window, preferences.displayAs) !== null,
);
if (preferences.pinned.length === 0) {
const marked = withPercent.filter((window) => window.summary);
return marked.length > 0 ? marked : withPercent.slice(0, 1);
}
return withPercent.filter((window) =>
preferences.pinned.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id),
);
}
/**
* The sidebar Usage item's summary: one group per account, grouped by source, each with its
* windows in its report's order. Accounts with nothing to show are left out.
*/
export function resolvePinnedUsage(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
): PinnedUsageWindow[] {
return preferences.pinned.flatMap((pin) =>
reports.flatMap((entry) => {
if (entry.sourceId !== pin.sourceId) return [];
const window = entry.report.windows.find((candidate) => candidate.id === pin.windowId);
const percent = window ? displayPercent(window, preferences.displayAs) : null;
if (!window || percent == null) return [];
return [
{
key: `${entry.id}/${window.id}`,
icon: entry.icon ?? null,
label: describe(entry, window.label),
percentText: formatDisplayPct(percent, preferences.displayAs),
},
];
}),
);
): PinnedUsageSource[] {
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
return groupBySource(reports).flatMap((entry) => {
const windows = summaryWindows(entry, preferences).map((window) => {
const percent = displayPercent(window, preferences.displayAs) ?? 0;
const percentText = formatPct(percent);
return {
key: `${entry.id}/${window.id}`,
label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
shortLabel: window.shortLabel ?? window.label,
percent,
percentText,
tone: windowTone(window),
};
});
if (windows.length === 0) return [];
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
});
}
+20 -2
View File
@@ -3,7 +3,9 @@ import {
DEFAULT_USAGE_PREFERENCES,
isUsagePinned,
setUsageDisplayAs,
setUsageHost,
toggleUsagePin,
UsagePreferencesSchema,
} from "./preferences";
const claudeFiveHour = { sourceId: "claude", windowId: "five-hour" };
@@ -23,17 +25,33 @@ describe("usage preferences", () => {
it("unpins a pinned window and keeps the others in order", () => {
const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }];
const unpinned = toggleUsagePin({ displayAs: "used", pinned: three }, claudeFiveHour);
const unpinned = toggleUsagePin(
{ displayAs: "used", pinned: three, serverId: null },
claudeFiveHour,
);
expect(unpinned.pinned).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]);
});
it("switches between used and remaining without touching pins", () => {
const preferences = { displayAs: "used" as const, pinned: [codexWeekly] };
const preferences = { displayAs: "used" as const, pinned: [codexWeekly], serverId: null };
expect(setUsageDisplayAs(preferences, "remaining")).toEqual({
displayAs: "remaining",
pinned: [codexWeekly],
serverId: null,
});
});
it("remembers the picked host without touching pins", () => {
expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pinned: [codexWeekly] }, "server")).toEqual(
{ displayAs: "used", pinned: [codexWeekly], serverId: "server" },
);
});
it("reads preferences saved before hosts could be picked as no pick", () => {
expect(UsagePreferencesSchema.parse({ displayAs: "remaining", pinned: [codexWeekly] })).toEqual(
{ displayAs: "remaining", pinned: [codexWeekly], serverId: null },
);
});
});
+12 -1
View File
@@ -13,14 +13,21 @@ export interface UsagePin {
export interface UsagePreferences {
displayAs: UsageDisplayAs;
pinned: UsagePin[];
/** The host the user picked to show usage for; null until they pick one. */
serverId: string | null;
}
export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { displayAs: "used", pinned: [] };
export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = {
displayAs: "used",
pinned: [],
serverId: null,
};
export const UsagePreferencesSchema = z
.object({
displayAs: z.enum(["used", "remaining"]).catch("used"),
pinned: z.array(z.object({ sourceId: z.string(), windowId: z.string() })).catch([]),
serverId: z.string().nullable().catch(null),
})
.catch(DEFAULT_USAGE_PREFERENCES);
@@ -46,3 +53,7 @@ export function setUsageDisplayAs(
): UsagePreferences {
return { ...preferences, displayAs };
}
export function setUsageHost(preferences: UsagePreferences, serverId: string): UsagePreferences {
return { ...preferences, serverId };
}
+179 -40
View File
@@ -1,8 +1,14 @@
import { router } from "expo-router";
import { Gauge } from "lucide-react-native";
import { useCallback, useMemo, useState } from "react";
import { CircleGauge } from "lucide-react-native";
import { Fragment, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
Pressable,
Text,
View,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
@@ -16,25 +22,32 @@ import { useUsageHostId, useUsageHostSelection } from "./hosts";
import type { UsagePreferences } from "./preferences";
import { useHostUsage } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import { resolvePinnedUsage, type PinnedUsageWindow } from "./pinned";
import { UsageMeter } from "./meter";
import {
choosePinnedUsageLayout,
METER_GAP,
MIN_METER_WIDTH,
resolvePinnedUsage,
type PinnedUsageLayout,
type PinnedUsageSource,
} from "./pinned";
import type { UsageReportEntry } from "./types";
import type { UsageHost } from "./model";
import { UsageBody } from "./usage-section";
const NO_REPORTS: UsageReportEntry[] = [];
const NO_ITEMS: PinnedUsageWindow[] = [];
const NO_SOURCES: PinnedUsageSource[] = [];
/**
* The sidebar footer's usage entry: each pinned window's source icon and percent, or a plain
* "Usage" row while no pinned window has data. Pressing it opens the Usage screen; on compact
* The sidebar footer's usage entry: each summary window's source icon and percent, or a plain
* "Usage" row while no summary window has data. Pressing it opens the Usage screen; on compact
* layouts it opens the usage sheet instead.
*/
export function UsageSidebarItem() {
const { preferences, display } = useUsagePreferences();
const serverId = useUsageHostId();
// Without pins there is nothing to summarize, so the host is not asked for reports.
if (!serverId || preferences.pinned.length === 0) {
return <UsageEntry serverId={serverId} items={NO_ITEMS} display={display} />;
if (!serverId) {
return <UsageEntry serverId={serverId} sources={NO_SOURCES} display={display} />;
}
return (
<PinnedUsageItem
@@ -57,11 +70,12 @@ function PinnedUsageItem({
}) {
const { view } = useHostUsage(serverId);
const reports = view.kind === "ready" ? view.reports : NO_REPORTS;
const items = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} items={items} display={display} />;
const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} sources={sources} display={display} />;
}
function useOpenUsageScreen(): () => void {
/** Opens the Usage screen, over the sidebar on compact layouts. */
export function useOpenUsageScreen(): () => void {
const isCompact = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
return useCallback(() => {
@@ -72,11 +86,11 @@ function useOpenUsageScreen(): () => void {
function UsageEntry({
serverId,
items,
sources,
display,
}: {
serverId: string | null;
items: readonly PinnedUsageWindow[];
sources: readonly PinnedUsageSource[];
display: UsageDisplay;
}) {
const { t } = useTranslation();
@@ -84,7 +98,7 @@ function UsageEntry({
const isCompact = useIsCompactFormFactor();
const openUsageScreen = useOpenUsageScreen();
const [open, setOpen] = useState(false);
// The sheet mounts on first open, so an unopened sidebar never asks the host for reports.
// The sheet mounts on first open; the summary already owns the report query.
const [sheetMounted, setSheetMounted] = useState(false);
// Without a host there are no reports to show, so compact goes to the screen, which says so.
const usesSheet = isCompact && serverId !== null;
@@ -98,12 +112,12 @@ function UsageEntry({
}, [openUsageScreen, usesSheet]);
const trigger =
items.length > 0 ? (
<PinnedUsageTrigger label={label} items={items} onPress={handlePress} />
sources.length > 0 ? (
<PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />
) : (
<SidebarHeaderRow
variant="inline"
icon={Gauge}
icon={CircleGauge}
label={label}
onPress={handlePress}
testID="sidebar-usage"
@@ -169,72 +183,197 @@ function HostUsageSheet({
);
}
function pinnedUsageLabel(label: string, items: readonly PinnedUsageWindow[]): string {
return `${label}: ${items.map((item) => `${item.label} ${item.percentText}`).join(", ")}`;
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
const windows = sources.flatMap((source) => source.windows);
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
}
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger;
}
function layoutWidth(event: LayoutChangeEvent): number {
return event.nativeEvent.layout.width;
}
/**
* One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure
* the line with labels and with percents alone; the richest layout that fits is shown, and what
* it leaves out takes no space at all.
*/
function PinnedUsageTrigger({
label,
items,
sources,
onPress,
}: {
label: string;
items: readonly PinnedUsageWindow[];
sources: readonly PinnedUsageSource[];
onPress: () => void;
}) {
const [available, setAvailable] = useState<number | null>(null);
const [labelsWidth, setLabelsWidth] = useState<number | null>(null);
const [percentsWidth, setPercentsWidth] = useState<number | null>(null);
const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => {
setAvailable(layoutWidth(event));
}, []);
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
setLabelsWidth(layoutWidth(event));
}, []);
const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => {
setPercentsWidth(layoutWidth(event));
}, []);
const layout = choosePinnedUsageLayout({
available,
labelsWidth,
percentsWidth,
windowCount: sources.reduce((count, source) => count + source.windows.length, 0),
});
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, items)}
accessibilityLabel={pinnedUsageLabel(label, sources)}
style={triggerStyle}
testID="sidebar-usage"
>
{items.map((item) => (
<View key={item.key} style={styles.item} testID="sidebar-usage-pinned-window">
<UsageSourceIcon svg={item.icon} size={14} />
<Text style={styles.percent} numberOfLines={1}>
{item.percentText}
</Text>
<View style={styles.line} onLayout={handleAvailableLayout}>
<PinnedUsageLine sources={sources} layout={layout} />
</View>
<View
style={styles.measure}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<View style={styles.measured} onLayout={handleLabelsLayout}>
<PinnedUsageLine sources={sources} layout="labels" measuring />
</View>
))}
<View style={styles.measured} onLayout={handlePercentsLayout}>
<PinnedUsageLine sources={sources} layout="percents" measuring />
</View>
</View>
</Pressable>
);
}
/**
* The line's items as direct children of one row, so every meter shares the leftover width
* equally.
*/
function PinnedUsageLine({
sources,
layout,
measuring = false,
}: {
sources: readonly PinnedUsageSource[];
layout: PinnedUsageLayout;
measuring?: boolean;
}) {
return sources.map((source, sourceIndex) => (
<Fragment key={source.key}>
<View
style={sourceIndex === 0 ? null : styles.sourceGap}
testID={measuring ? undefined : "sidebar-usage-source"}
>
<UsageSourceIcon svg={source.icon} size={14} />
</View>
{source.windows.map((window, windowIndex) => {
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
return (
<Fragment key={window.key}>
{layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}>
<UsageMeter
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
</View>
) : null}
<Text
style={[styles.percent, layout === "meters" ? null : leadStyle]}
numberOfLines={1}
testID={measuring ? undefined : "sidebar-usage-pinned-window"}
>
{window.percentText}
{layout === "percents" || window.shortLabel === "" ? null : (
<Text style={styles.windowLabel}>{` ${window.shortLabel}`}</Text>
)}
</Text>
</Fragment>
);
})}
</Fragment>
));
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
// A wrapping row packs its lines at the top; center them in the 28px row instead.
alignContent: "center",
columnGap: theme.spacing[3],
rowGap: theme.spacing[1],
overflow: "hidden",
// Same row geometry and leading rail as Add project and the footer icons.
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1.5],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
item: {
line: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
overflow: "hidden",
},
// Wide enough that nothing inside wraps or shrinks; each child reports its natural width.
measure: {
position: "absolute",
top: 0,
left: 0,
width: 10_000,
opacity: 0,
},
measured: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
},
sourceGap: {
marginLeft: theme.spacing[3],
},
iconGap: {
marginLeft: theme.spacing[1.5],
},
windowGap: {
marginLeft: theme.spacing[2],
},
// Every slot takes an equal share of the leftover width, so the line fills the row.
meterSlot: {
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
minWidth: MIN_METER_WIDTH,
marginRight: METER_GAP,
},
meter: {
height: 6,
borderRadius: 3,
},
percent: {
color: theme.colors.foregroundMuted,
flexShrink: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontVariant: ["tabular-nums"],
},
windowLabel: {
color: theme.colors.foregroundMuted,
},
sheetBody: {
padding: theme.spacing[3],
gap: theme.spacing[3],
+6 -2
View File
@@ -1,6 +1,10 @@
import type { UsageTone } from "./types";
import { usedPercent } from "./model";
import type { UsageTone, UsageWindow } from "./types";
export function deriveTone(usedPct: number | null | undefined): UsageTone {
/** The source's tone for a window, or one derived from how much of it is used. */
export function windowTone(window: UsageWindow): UsageTone {
if (window.tone) return window.tone;
const usedPct = usedPercent(window);
if (usedPct == null) return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
+12 -52
View File
@@ -1,25 +1,13 @@
import { useMemo } from "react";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatDisplayPct, formatResetLabel } from "./format";
import { displayPercent, usageWindowRowLabel, usedPercent } from "./model";
import { formatDisplayPct, formatResetLabel } from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import type { UsageDisplayAs } from "./preferences";
import { deriveTone } from "./tone";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
function highlightStyle(pinned: boolean, hovered: boolean) {
@@ -43,15 +31,8 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
const usedPct = usedPercent(window);
const shownPct = displayPercent(window, displayAs);
const tone = window.tone ?? deriveTone(usedPct);
const fillWidth = clampPct(shownPct ?? 0);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }],
[fillWidth, tone],
);
const tone = windowTone(window);
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
const trailing = isAtRisk
@@ -80,7 +61,8 @@ export function UsageWindowBar({
value={value}
trailing={trailing}
isAtRisk={isAtRisk}
fillStyle={fillStyle}
percent={shownPct ?? 0}
tone={tone}
/>
)}
</Pressable>
@@ -93,14 +75,16 @@ function WindowRowContent({
value,
trailing,
isAtRisk,
fillStyle,
percent,
tone,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
value: string;
trailing: string | null | undefined;
isAtRisk: boolean;
fillStyle: StyleProp<ViewStyle>;
percent: number;
tone: UsageTone;
}) {
return (
<>
@@ -116,9 +100,7 @@ function WindowRowContent({
) : null}
</Text>
</View>
<View style={styles.track}>
<View style={fillStyle} />
</View>
<UsageMeter percent={percent} tone={tone} />
</>
);
}
@@ -174,26 +156,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
@@ -12,6 +12,10 @@ export const BottomSheetScrollView = PassThrough;
export const BottomSheetView = PassThrough;
export const BottomSheetTextInput = Stub;
export function useBottomSheetModal(): never {
throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests");
}
// The app imports the internal layout values to compute the visible sheet body. Browser unit tests
// never render the native sheet, but Vite still validates this module's export surface.
const sharedValue = (value: unknown) => ({ get: () => value });
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.10.0",
"version": "0.11.0-beta.1",
"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.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/server": "0.11.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

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