mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-18 07:12:35 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6166a7aca5 | ||
|
|
31cd18e748 | ||
|
|
05e94ab52e | ||
|
|
b5b43edd65 | ||
|
|
8498c74169 | ||
|
|
a52bdb106c | ||
|
|
70df09daeb | ||
|
|
e10f6d2e2c | ||
|
|
95774ba71f | ||
|
|
37b66897a9 | ||
|
|
4aa2f2817d | ||
|
|
bdb8e13221 | ||
|
|
06213e49bf | ||
|
|
d30e99c858 | ||
|
|
e939dd4c7a | ||
|
|
15f1431416 | ||
|
|
b701cb2dcd | ||
|
|
4893629ff5 | ||
|
|
e0a0cf990d | ||
|
|
7b0cace991 | ||
|
|
20a887f4cc | ||
|
|
a7c9c05bef | ||
|
|
1f5f5d384e | ||
|
|
695ecb2812 | ||
|
|
7a64eef491 | ||
|
|
9d9d9af993 | ||
|
|
29c198f955 | ||
|
|
318a84a570 | ||
|
|
364e6456e0 | ||
|
|
d070db15b3 | ||
|
|
4417d7c47a | ||
|
|
8332f4d1ff |
@@ -9,6 +9,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
|
||||
+107
@@ -1,5 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
## 0.11.0-beta.2 - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]`) |
|
||||
|
||||
+4
-2
@@ -182,8 +182,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
||||
Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
|
||||
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed
|
||||
skill directories and keeps config plus filesystem convergence behind one serialized owner.
|
||||
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared
|
||||
`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind
|
||||
one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from
|
||||
Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files.
|
||||
|
||||
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
|
||||
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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).
|
||||
@@ -34,7 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing 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 (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-9
@@ -88,12 +88,32 @@ 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.
|
||||
|
||||
Desktop packaging ships the entire built-in plugin directory as an external resource,
|
||||
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
|
||||
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
|
||||
Built-ins can import only host modules (`@getpaseo/plugin/*`, `zod`) and Node built-ins:
|
||||
outside the archive, nothing resolves an npm dependency, and the dist build test cannot catch
|
||||
one because it resolves through the repository's `node_modules`.
|
||||
The packaged-app smoke check requires every listed built-in to start without relying on
|
||||
account credentials.
|
||||
|
||||
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 +440,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
|
||||
@@ -449,7 +473,7 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
@@ -463,7 +487,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
|
||||
|
||||
+19
-54
@@ -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.
|
||||
|
||||
@@ -215,15 +187,8 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
## Usage sources
|
||||
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
|
||||
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
|
||||
|
||||
### Credentials are read only
|
||||
|
||||
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
contract, account identity, login fallback, and read-only credential rules.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
sha256-UZ9fPdCIJzghuxPXK9Kzt5fJmjyVwfaWsGiwXAh/zZI=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/server": "0.11.0-beta.3",
|
||||
"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.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37747,7 +37747,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@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.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"@getpaseo/highlight": "0.11.0-beta.3",
|
||||
"@getpaseo/plugin": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@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.3",
|
||||
"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.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"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"],
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -40,22 +42,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 {
|
||||
@@ -153,6 +154,8 @@ test.describe("Plugin sidebar items", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
// The Usage item shows only with summary data.
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
@@ -188,7 +191,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);
|
||||
@@ -130,6 +184,8 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
|
||||
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
// The Usage item shows only with summary data.
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
// Keys for the fixed footer icon buttons are ignored.
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "help", visible: false },
|
||||
@@ -147,6 +203,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 +217,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 +227,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]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
import { tmpdir } from "node:os";
|
||||
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 {
|
||||
installLoginUsage,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import { expectPinnedUsage, togglePin, usageItem } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`);
|
||||
test.use({
|
||||
e2eDaemonEnvironment: {
|
||||
HOME: emptyHome,
|
||||
USERPROFILE: emptyHome,
|
||||
CODEX_HOME: emptyHome,
|
||||
CLAUDE_CONFIG_DIR: emptyHome,
|
||||
XDG_DATA_HOME: emptyHome,
|
||||
PI_CODING_AGENT_DIR: emptyHome,
|
||||
COPILOT_TOKEN: "",
|
||||
GITHUB_TOKEN: "",
|
||||
GITHUB_PAT: "",
|
||||
CURSOR_ACCESS_TOKEN: "",
|
||||
CURSOR_TOKEN: "",
|
||||
GROK_API_KEY: "",
|
||||
GROK_TOKEN: "",
|
||||
KIMI_TOKEN: "",
|
||||
KIMI_API_KEY: "",
|
||||
KIMI_CODE_HOME: emptyHome,
|
||||
MINIMAX_API_KEY: "",
|
||||
ZAI_API_KEY: "",
|
||||
GLM_API_KEY: "",
|
||||
},
|
||||
});
|
||||
|
||||
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 +64,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, {
|
||||
@@ -51,7 +105,10 @@ test.describe("usage screen", () => {
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: { status: "unavailable", windows: [] },
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -64,10 +121,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 +157,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 +174,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();
|
||||
|
||||
@@ -167,15 +219,140 @@ test.describe("usage screen", () => {
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]: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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const loginWindows: UsageReportEntry["report"] = {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 },
|
||||
{ id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 },
|
||||
],
|
||||
};
|
||||
|
||||
test("expired login refreshes to windows with visible pin toggles", async ({ page }) => {
|
||||
const fixture = await installLoginUsage({
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-expired-login");
|
||||
await fixture.setReport(loginWindows);
|
||||
await refreshLoginUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "0");
|
||||
await hoverUsageWindow(page, "Weekly");
|
||||
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await qaScreenshot(page, "usage-pin-hover");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await expectPinnedUsage(page, ["54% wk"]);
|
||||
await qaScreenshot(page, "usage-pin-selected");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
const fixture = await installLoginUsage(loginWindows);
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-unpinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(1)).toHaveCSS("opacity", "1");
|
||||
await qaScreenshot(page, "usage-pin-compact");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,14 +3,24 @@ 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;
|
||||
report: Partial<UsageReportEntry["report"]>;
|
||||
report:
|
||||
| Partial<Extract<UsageReportEntry["report"], { status: "available" }>>
|
||||
| Exclude<UsageReportEntry["report"], { status: "available" }>;
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:account`,
|
||||
@@ -19,11 +29,14 @@ function report(input: {
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
icon: ICON,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
report:
|
||||
input.report.status === "error" || input.report.status === "unavailable"
|
||||
? input.report
|
||||
: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +82,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 +95,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 +104,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 +120,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,30 @@ 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 {
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectNoPinnedUsage,
|
||||
expectNoUsageItem,
|
||||
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 +42,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 +51,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 +93,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 +141,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 +164,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 +176,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 +205,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 +226,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 +249,86 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("without summary data the footer drops the Usage item and keeps the Usage icon", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: new Date().toISOString(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectNoUsageItem(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type AgentSkillsClient = Pick<
|
||||
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
|
||||
>;
|
||||
|
||||
export type SkillTargetName = "agents" | "claude" | "codex";
|
||||
export type SkillTargetName = "agents" | "claude";
|
||||
|
||||
export interface AgentSkillsSandbox {
|
||||
available: string[];
|
||||
@@ -30,6 +30,7 @@ export interface AgentSkillsSandbox {
|
||||
daemon: IsolatedHostDaemon;
|
||||
home: string;
|
||||
targets: Record<SkillTargetName, string>;
|
||||
codexSkillsDir: string;
|
||||
blockAgentsDirectory(): Promise<void>;
|
||||
unblockAgentsDirectory(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -63,8 +64,8 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||
const targets = {
|
||||
agents: path.join(home, ".agents", "skills"),
|
||||
claude: path.join(home, ".claude", "skills"),
|
||||
codex: path.join(home, ".codex", "skills"),
|
||||
};
|
||||
const codexSkillsDir = path.join(home, ".codex", "skills");
|
||||
|
||||
return {
|
||||
available,
|
||||
@@ -72,6 +73,7 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||
daemon,
|
||||
home,
|
||||
targets,
|
||||
codexSkillsDir,
|
||||
blockAgentsDirectory: async () => {
|
||||
await mkdir(path.dirname(targets.agents), { recursive: true });
|
||||
await writeFile(targets.agents, "not a directory", "utf8");
|
||||
@@ -196,6 +198,7 @@ export async function expectInstalledSkills(
|
||||
for (const directory of Object.values(sandbox.targets)) {
|
||||
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
|
||||
}
|
||||
await expect.poll(() => readInstalledSkills(sandbox.codexSkillsDir)).toEqual([]);
|
||||
}
|
||||
|
||||
export async function expectSavedSelection(
|
||||
|
||||
@@ -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";
|
||||
@@ -162,7 +163,6 @@ async function expectVerticalOrder<Key extends string>(
|
||||
|
||||
/** Persisted footer row key -> the testID the app shell renders that row with. */
|
||||
function shellFooterTestID(key: string): string {
|
||||
// With nothing pinned the Usage item is the plain Usage row.
|
||||
if (key === "usage") return "sidebar-usage";
|
||||
const [, pluginId, itemId] = key.split(":");
|
||||
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
|
||||
@@ -218,24 +218,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;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UsageReport } from "@getpaseo/protocol/messages";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
/** A real store-backed plugin on the worker's isolated daemon. */
|
||||
export async function installLoginUsage(initialReport: UsageReport) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const store = path.join(directory, "report.json");
|
||||
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("login-journey");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await setReport(initialReport);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "login-journey", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {z} from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
|
||||
discover: async () => [{key: "account", input: {}}],
|
||||
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { setReport, cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUsage(page: Page) {
|
||||
await page.goto("/usage");
|
||||
}
|
||||
|
||||
export async function refreshLoginUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function hoverUsageWindow(page: Page, window: string) {
|
||||
await page
|
||||
.getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) })
|
||||
.getByTestId("usage-pin-glyph-unpinned")
|
||||
.hover();
|
||||
}
|
||||
@@ -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,28 @@ 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,
|
||||
status: entry.report.status,
|
||||
windows: entry.report.status === "available" ? entry.report.windows : [],
|
||||
balances: entry.report.status === "available" ? entry.report.balances : undefined,
|
||||
details: entry.report.status === "available" ? entry.report.details : undefined,
|
||||
planLabel:
|
||||
entry.report.status === "available" ? (entry.report.planLabel ?? null) : null,
|
||||
error: entry.report.status === "error" ? entry.report.error : null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { type: "usage.list_reports.response", payload: { requestId, reports } };
|
||||
ws.send(JSON.stringify({ type: "session", message: reply }));
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +185,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,58 @@
|
||||
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 () => [{key: "scrolling-account", input: {}}],
|
||||
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 +79,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 +109,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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -57,7 +136,7 @@ function visible(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
|
||||
/** The sidebar footer's Usage item: each summary window with data. */
|
||||
export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
@@ -71,15 +150,16 @@ 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". */
|
||||
export async function expectNoPinnedUsage(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
|
||||
await expect(usageItem(page)).toHaveText("Usage");
|
||||
/** Without a summary window with data the footer has no Usage item, only the Usage icon. */
|
||||
export async function expectNoUsageItem(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible();
|
||||
}
|
||||
|
||||
/** A window row, which is itself the pin toggle: "Claude", "Session". */
|
||||
@@ -95,8 +175,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. */
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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, useHasUsageSummary, 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}
|
||||
@@ -509,28 +457,37 @@ function SidebarFooter({
|
||||
);
|
||||
}
|
||||
|
||||
/** The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. */
|
||||
/**
|
||||
* The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. The
|
||||
* Usage item is left out while it has no summary to show.
|
||||
*/
|
||||
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const { items } = useSidebarNavItems("footer");
|
||||
const hasUsageSummary = useHasUsageSummary();
|
||||
const rowsRef = useRef<View | null>(null);
|
||||
const visibleItems = items.filter((item) => item.visible);
|
||||
const visibleItems = items.filter(
|
||||
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
|
||||
);
|
||||
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 +544,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 +597,6 @@ function MobileSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -814,7 +774,6 @@ function DesktopSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -913,7 +872,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 +919,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,
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ function statusText(report: UsageReport): string | null {
|
||||
return report.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function reportContent(report: UsageReport) {
|
||||
if (report.status === "available")
|
||||
return {
|
||||
windows: report.windows,
|
||||
balances: report.balances ?? [],
|
||||
details: report.details ?? [],
|
||||
message: undefined,
|
||||
};
|
||||
const message =
|
||||
report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error;
|
||||
return { windows: [], balances: [], details: [], message };
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
@@ -48,8 +61,7 @@ export function UsageCard({
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
const { windows, balances, details, message } = reportContent(usage);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -71,7 +83,9 @@ export function UsageCard({
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" size="xs" /> : null}
|
||||
{usage.status === "available" && usage.planLabel ? (
|
||||
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
|
||||
) : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
@@ -88,15 +102,15 @@ export function UsageCard({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
{message ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
{windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
{windows.map((window) => (
|
||||
<PinnableWindowBar key={window.id} entry={entry} window={window} display={display} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { usageCopy } from "./copy";
|
||||
|
||||
test.each([
|
||||
[
|
||||
{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z", refreshedBy: "claude" },
|
||||
"Login expired 3h ago. Run claude to refresh it.",
|
||||
],
|
||||
[{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z" }, "Login expired 3h ago. Sign in again."],
|
||||
[
|
||||
{ kind: "rejected", status: 403, refreshedBy: "codex" },
|
||||
"Login rejected (HTTP 403). Run codex to refresh it.",
|
||||
],
|
||||
[{ kind: "rejected", status: 401 }, "Login rejected (HTTP 401). Sign in again."],
|
||||
[{ kind: "no_quota", detail: "No active coding plan" }, "No active coding plan"],
|
||||
] as const)("problem %j", (problem, sentence) => {
|
||||
expect(usageCopy.problem(problem, new Date("2026-10-01T12:00:00Z"))).toBe(sentence);
|
||||
});
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { UsageProblem } from "@getpaseo/protocol/messages";
|
||||
import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
|
||||
// User-facing copy for the usage surfaces, kept in one file so localization is a
|
||||
// single-file change.
|
||||
export const usageCopy = {
|
||||
problem: (problem: UsageProblem, now: Date = new Date()): string => {
|
||||
if (problem.kind === "no_quota") return problem.detail;
|
||||
const remedy = problem.refreshedBy
|
||||
? `Run ${problem.refreshedBy} to refresh it.`
|
||||
: "Sign in again.";
|
||||
if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`;
|
||||
const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now));
|
||||
return `Login expired ${ago}. ${remedy}`;
|
||||
},
|
||||
title: "Usage",
|
||||
planUsage: "Plan usage",
|
||||
options: "Usage options",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
@@ -16,7 +29,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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
export { UsageSidebarItem } from "./sidebar-item";
|
||||
export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,29 @@ 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%"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unavailable and error reports out of the sidebar summary", () => {
|
||||
const expired: UsageReportEntry = {
|
||||
...claude,
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" },
|
||||
},
|
||||
};
|
||||
const failed: UsageReportEntry = {
|
||||
...codex,
|
||||
report: { status: "error", error: "Store deleted" },
|
||||
};
|
||||
expect(resolvePinnedUsage([expired, failed], preferences([]))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -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,53 @@ 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[] {
|
||||
if (entry.report.status !== "available") return [];
|
||||
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 }];
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user