mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 14:22:42 +00:00
Compare commits
62
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 | ||
|
|
7b7685678d | ||
|
|
5715b5ba62 | ||
|
|
9952c55983 | ||
|
|
e9e479c6ba | ||
|
|
2553ab4d82 | ||
|
|
ad56659a89 | ||
|
|
b0c9de0c90 | ||
|
|
8cccc1f6fa | ||
|
|
3a9304607c | ||
|
|
b1a3b0772d | ||
|
|
5599f9e567 | ||
|
|
4e9a4582b7 | ||
|
|
3fea128382 | ||
|
|
53ee9cd993 | ||
|
|
767b0e970b | ||
|
|
d3c902e9fa | ||
|
|
60aff2346c | ||
|
|
5de45e2086 | ||
|
|
5764863f63 | ||
|
|
4b45bc9fc1 | ||
|
|
a8f5d969b8 | ||
|
|
23c4404b95 | ||
|
|
2838922ade | ||
|
+1 |
264f6e1cca | ||
|
|
cc744cc669 | ||
|
|
aff476b01b | ||
|
|
d596861891 | ||
|
|
be2fb1cab9 | ||
|
|
87818812ad | ||
|
|
b9ac1d4982 |
@@ -9,6 +9,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
|
||||
+117
@@ -1,5 +1,122 @@
|
||||
# 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
|
||||
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
|
||||
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
|
||||
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
|
||||
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
|
||||
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
|
||||
Only the Claude provider does this today; the others still report a death only when a turn happens to
|
||||
be in flight.
|
||||
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
|
||||
the unfinished turn is lost.
|
||||
|
||||
### Cancellation
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ Terminology:
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
|
||||
@@ -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
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **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,6 +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, 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
|
||||
|
||||
+140
-20
@@ -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
|
||||
|
||||
@@ -259,12 +279,23 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
import { Greeting } from "./client/greeting";
|
||||
|
||||
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||||
return (
|
||||
<SidebarRow
|
||||
icon="MessageCircle"
|
||||
active={currentScreen?.screenId === "main"}
|
||||
onPress={() => openScreen({ screenId: "main" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Greeting);
|
||||
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
|
||||
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -277,15 +308,33 @@ errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
|
||||
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
|
||||
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
|
||||
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
|
||||
that id, so links saved before a plugin migrated keep working.
|
||||
|
||||
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
|
||||
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
|
||||
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
|
||||
keys are stored under a `param.` prefix; plugins never see it and may use any key.
|
||||
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
|
||||
params.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
|
||||
`useRpc()` to that host. An offline selected host fails there and never falls through to another
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
|
||||
|
||||
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
|
||||
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
|
||||
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
|
||||
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
|
||||
disposed per mounted surface. See the
|
||||
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
|
||||
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
@@ -321,7 +370,7 @@ existing agent-context instances, but it cannot create an agent panel without an
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
|
||||
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
@@ -352,9 +401,38 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
|
||||
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
|
||||
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
|
||||
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
|
||||
environment. Spawn with those values; do not merge the plugin process's environment back in.
|
||||
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
|
||||
Without a declared command, existing providers keep their connection behavior and receive no launch.
|
||||
|
||||
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
|
||||
that effective launch. Return `{ available: true }` or
|
||||
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
|
||||
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
|
||||
A command-backed provider without status is available when its executable resolves. Providers with
|
||||
neither command nor status retain connection-based availability.
|
||||
|
||||
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
|
||||
description, and model configuration through the normal provider registry. `enabled: false` disables
|
||||
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
|
||||
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
|
||||
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
|
||||
settles (including disabled plugins), then on each installed registry generation.
|
||||
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
|
||||
remain valid for plugin registration but cannot be used as config provider IDs.
|
||||
|
||||
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
|
||||
connection receives the new launch without restarting the daemon. Running sessions keep their
|
||||
current launch until refreshed, as native providers do.
|
||||
|
||||
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
|
||||
probes. The callback runs in the plugin process before discovery and receives the actual global or
|
||||
workspace target. Return a key covering effective configuration and execution environment, or
|
||||
workspace target plus the resolved `launch` when a command is declared. Return a key covering
|
||||
effective configuration and execution environment, or
|
||||
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
|
||||
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
|
||||
|
||||
@@ -362,15 +440,22 @@ 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. Providers re-read credentials, environment, global configuration, and MCP servers on
|
||||
`session.open`; there is no provider reload input.
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
no provider reload input.
|
||||
|
||||
For an ACP command, register `runAcpProvider({ id, label, command })` from
|
||||
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
|
||||
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
|
||||
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
|
||||
only the supplied per-session env for sessions. The direct and ACP examples live in
|
||||
`plugin-examples/provider-direct`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
@@ -388,10 +473,45 @@ 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.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
Sidebar header and footer items are plugin components, not descriptors. The
|
||||
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
|
||||
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
|
||||
one section's order from built-ins, plugin groups, and the section's preference
|
||||
(`sidebarNavItems` or `sidebarFooterItems`).
|
||||
|
||||
- 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,
|
||||
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
|
||||
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
|
||||
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
|
||||
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
|
||||
the responder system, so the row's own press anchors too. Until a press, the first mounted row
|
||||
holds the anchor. An item that renders no kit component
|
||||
anchors to its section container.
|
||||
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
|
||||
the line runs edge to edge in both sections.
|
||||
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
|
||||
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
|
||||
row; a button inside it wins its own press because only the innermost pressable responds.
|
||||
- A new-API item renders from the current route's host, else the host remembered under
|
||||
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
|
||||
set. One key per plugin: switching host on any of its screens moves all its items.
|
||||
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
|
||||
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
|
||||
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
|
||||
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
@@ -461,7 +581,7 @@ be rendered intact. The daemon advertises this RPC through
|
||||
|
||||
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
|
||||
+37
-54
@@ -1,52 +1,38 @@
|
||||
# Adding a New Provider to Paseo
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
Add new providers through the plugin SDK. The core adapter patterns below describe the existing
|
||||
server integrations.
|
||||
|
||||
## Plugin providers
|
||||
|
||||
Keep a bundled provider in `plugins/<id>/` and register it through
|
||||
`@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.
|
||||
|
||||
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
|
||||
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
|
||||
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.
|
||||
|
||||
| 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:
|
||||
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.
|
||||
|
||||
- **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.
|
||||
|
||||
## Two Integration Patterns
|
||||
## Core adapter patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
@@ -69,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.
|
||||
|
||||
@@ -85,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.
|
||||
|
||||
@@ -99,6 +87,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
|
||||
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
@@ -197,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 optional `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. Mark the window the app should show first with `headline: true`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -293,7 +276,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
|
||||
### 2. Add to the provider manifest
|
||||
|
||||
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
|
||||
First, define the modes with visual metadata:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
+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,122 @@
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
|
||||
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
|
||||
// The real daemon discovers both through the same Codex model/list boundary.
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
|
||||
},
|
||||
claude: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function openSpeedSelector(page: Page): Promise<void> {
|
||||
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
|
||||
const features = page.getByRole("button", { name: "Open agent features", exact: true });
|
||||
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
|
||||
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (!(await speed.isVisible())) {
|
||||
if (await features.isVisible()) await features.click();
|
||||
else await modelSettings.click();
|
||||
}
|
||||
await expect(speed).toBeVisible({ timeout: 30_000 });
|
||||
await speed.click();
|
||||
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectSpeed(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: label, exact: true }).last().click();
|
||||
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
|
||||
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
|
||||
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
|
||||
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
|
||||
else await expect(sheetTrigger).toContainText(label);
|
||||
}
|
||||
|
||||
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
|
||||
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
|
||||
const option = page.getByText(choice, { exact: true }).last();
|
||||
if (choices.includes(choice)) {
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option).toBeInViewport({ ratio: 1 });
|
||||
} else await expect(option).not.toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
|
||||
const client = await connectDaemonClient<
|
||||
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
|
||||
>({
|
||||
clientIdPrefix: "codex-speed-model",
|
||||
});
|
||||
try {
|
||||
await client.setAgentModel(agentId, modelId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 850 },
|
||||
{ name: "compact", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(viewport);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Speed settings",
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
initialPrompt: "Get ready to work on this project.",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
if (viewport.name === "desktop") {
|
||||
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(trigger).toHaveText("");
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
|
||||
}
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
||||
await selectSpeed(page, "Ultrafast");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
||||
});
|
||||
await selectSpeed(page, "Normal");
|
||||
if (viewport.name === "desktop") {
|
||||
await switchAgentModel(agent.id, "gpt-6-sol");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectContentColumnWidth,
|
||||
expectCustomContentWidthSetting,
|
||||
expectDefaultContentWidthSetting,
|
||||
openAppearanceSettings,
|
||||
resetContentWidth,
|
||||
setContentWidth,
|
||||
} from "../support/helpers/content-width";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
|
||||
|
||||
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "content-width-",
|
||||
title: "Content width",
|
||||
initialPrompt: "Write a long paragraph.",
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
|
||||
const assistantMessage = page.getByTestId("assistant-message").first();
|
||||
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
|
||||
|
||||
await test.step("chat starts at the default width", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectContentColumnWidth(assistantMessage, { max: 820 });
|
||||
});
|
||||
|
||||
await test.step("settings show the default without a reset", async () => {
|
||||
await openAppearanceSettings(page);
|
||||
await expectDefaultContentWidthSetting(page, 820);
|
||||
});
|
||||
|
||||
await test.step("a custom width is saved and offers a reset", async () => {
|
||||
await setContentWidth(page, 1600);
|
||||
await expectCustomContentWidthSetting(page, 1600);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
|
||||
});
|
||||
|
||||
await test.step("chat and Markdown files use the custom width", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
|
||||
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
|
||||
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
|
||||
});
|
||||
|
||||
await test.step("reset returns to the default without storing it", async () => {
|
||||
await openAppearanceSettings(page);
|
||||
await resetContentWidth(page);
|
||||
await expectDefaultContentWidthSetting(page, 820);
|
||||
|
||||
await openAgentRoute(page, agent);
|
||||
await expectContentColumnWidth(assistantMessage, { max: 820 });
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
expectConflictPreservesDraft,
|
||||
discardTitle,
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
|
||||
await discardTitle(page);
|
||||
});
|
||||
|
||||
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
LEGACY_PLUGIN_ID,
|
||||
SHOWCASE_PLUGIN_ID,
|
||||
WIDE,
|
||||
closeScreen,
|
||||
expectRowActive,
|
||||
footerItem,
|
||||
headerRow,
|
||||
installSidebarPlugins,
|
||||
popoverBox,
|
||||
runCommand,
|
||||
visibleTestId,
|
||||
} from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} 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";
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area?: Locator) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let popover, sheet and drawer animations settle so the image shows the final state.
|
||||
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, `${name}.png`);
|
||||
if (area) await area.screenshot({ path: file });
|
||||
else await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
/** 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 settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!;
|
||||
const box = (await row.boundingBox())!;
|
||||
return {
|
||||
aboveBottomLine: box.y + box.height <= addProject.y,
|
||||
alignedLeft: Math.abs(box.x - addProject.x) < 1,
|
||||
alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1,
|
||||
};
|
||||
})
|
||||
.toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true });
|
||||
}
|
||||
|
||||
function botRow(page: Page, rowId: string): Locator {
|
||||
return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`);
|
||||
}
|
||||
|
||||
async function boxOf(locator: Locator) {
|
||||
const result = await locator.boundingBox();
|
||||
expect(result).not.toBeNull();
|
||||
return result!;
|
||||
}
|
||||
|
||||
/** The open screen's header title; a pushed screen keeps the one below it mounted. */
|
||||
function screenTitle(page: Page): Locator {
|
||||
return page.locator('[data-testid="plugin-surface-title"]:visible').last();
|
||||
}
|
||||
|
||||
async function expectBotScreen(page: Page, botId: string) {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`),
|
||||
);
|
||||
await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible();
|
||||
// The plugin titles the screen from its params: the bot's name.
|
||||
await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2");
|
||||
await expectRowActive(botRow(page, "bot-1"), botId === "bot-1");
|
||||
await expectRowActive(botRow(page, "bot-2"), botId === "bot-2");
|
||||
await expectRowActive(botRow(page, "status"), false);
|
||||
}
|
||||
|
||||
/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */
|
||||
async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) {
|
||||
const popover = await popoverBox(page, text);
|
||||
const rowBox = await boxOf(row);
|
||||
const otherBox = await boxOf(elsewhere);
|
||||
expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width);
|
||||
expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40);
|
||||
expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20);
|
||||
}
|
||||
|
||||
/** Subscription ids the daemon confirms released, collected from the page's websocket. */
|
||||
function trackReleasedSubscriptions(page: Page): Set<string> {
|
||||
const released = new Set<string>();
|
||||
page.on("websocket", (socket) =>
|
||||
socket.on("framereceived", ({ payload }) => collectRelease(released, payload)),
|
||||
);
|
||||
return released;
|
||||
}
|
||||
|
||||
function collectRelease(released: Set<string>, payload: string | Buffer) {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.message?.type === "subscription.release.response")
|
||||
released.add(frame.message.payload.subscriptionId);
|
||||
}
|
||||
|
||||
async function expectReleased(released: Set<string>, subscriptionId: string) {
|
||||
await expect.poll(() => released.has(subscriptionId)).toBe(true);
|
||||
}
|
||||
|
||||
function readAlertsObservation(page: Page): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
() => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/** The id of the observation the Bot alerts item opened after `previous`. */
|
||||
async function nextAlertsObservation(page: Page, previous: string | null): Promise<string> {
|
||||
await expect.poll(() => readAlertsObservation(page)).not.toBe(previous);
|
||||
return (await readAlertsObservation(page))!;
|
||||
}
|
||||
|
||||
function sidebarFooter(page: Page): Locator {
|
||||
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
||||
}
|
||||
|
||||
test.describe("Plugin sidebar items", () => {
|
||||
let workspaceId: string;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" });
|
||||
const plugins = await installSidebarPlugins();
|
||||
workspaceId = workspace.workspaceId;
|
||||
cleanup = async () => {
|
||||
await plugins.cleanup();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a plugin's screen, header row, footer popover and command work together", async ({
|
||||
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");
|
||||
const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sync).toBeVisible();
|
||||
|
||||
await test.step("a throwing item renders nothing and leaves the sidebar working", async () => {
|
||||
await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0);
|
||||
await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0);
|
||||
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the header row highlights while its screen is open", async () => {
|
||||
await expectRowActive(row, false);
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`));
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expect(screenTitle(page)).toHaveText("Deploys");
|
||||
await expectRowActive(row, true);
|
||||
await qaScreenshot(page, "phase2-desktop-sidebar-screen");
|
||||
await closeScreen(page);
|
||||
await expectRowActive(row, false);
|
||||
});
|
||||
|
||||
await test.step("the trailing button presses on its own", async () => {
|
||||
await page.getByRole("button", { name: "Refresh deploys", exact: true }).click();
|
||||
await expect(row).toHaveAccessibleName("Deploys (refreshed 1)");
|
||||
await expect(page).not.toHaveURL(/\/plugin\//);
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
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();
|
||||
await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page));
|
||||
});
|
||||
|
||||
await test.step("the footer row opens a popover anchored to it", async () => {
|
||||
await sync.click();
|
||||
const popover = await popoverBox(page);
|
||||
const button = (await sync.boundingBox())!;
|
||||
await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible();
|
||||
expect(popover.y + popover.height).toBeLessThanOrEqual(button.y);
|
||||
expect(button.y - popover.y).toBeLessThan(300);
|
||||
expect(Math.abs(popover.x - button.x)).toBeLessThan(80);
|
||||
await qaScreenshot(page, "phase2-desktop-popover");
|
||||
await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click();
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("the command center item opens the screen", async () => {
|
||||
await runCommand(page, "Open deploys");
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expectRowActive(row, true);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("on a compact layout the popover is a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(compactSync).toBeInViewport();
|
||||
await expectFooterRow(page, compactSync);
|
||||
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
|
||||
await qaScreenshot(page, "phase2-compact-sidebar");
|
||||
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
|
||||
await compactSync.click();
|
||||
await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible();
|
||||
const sheet = await popoverBox(page);
|
||||
expect(sheet.y).toBeGreaterThan(COMPACT.height / 2);
|
||||
await qaScreenshot(page, "phase2-compact-sheet");
|
||||
await page.getByRole("button", { name: "Close sync details", exact: true }).click();
|
||||
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("a header row's shortcut hint presses the row", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace");
|
||||
await expect(newWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
await newWorkspace.hover();
|
||||
const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true");
|
||||
await expect(hint).toBeVisible();
|
||||
await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../.."));
|
||||
await hint.click();
|
||||
await expect(page).toHaveURL(/\/new(\?|$)/);
|
||||
});
|
||||
|
||||
test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.addInitScript(
|
||||
({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })),
|
||||
{
|
||||
key: APP_SETTINGS_KEY,
|
||||
sidebarNavItems: [
|
||||
{ key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true },
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry");
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
let entryIcon = "";
|
||||
|
||||
await test.step("the saved order and visibility still apply", async () => {
|
||||
await expect(entry).toHaveAccessibleName("Legacy entry");
|
||||
await expect(entry.locator("svg")).toHaveCount(1);
|
||||
entryIcon = await entry.locator("svg").innerHTML();
|
||||
await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0);
|
||||
const entryBox = (await entry.boundingBox())!;
|
||||
const newWorkspaceBox = (await visibleTestId(
|
||||
page,
|
||||
"sidebar-global-new-workspace",
|
||||
).boundingBox())!;
|
||||
expect(entryBox.y).toBeLessThan(newWorkspaceBox.y);
|
||||
});
|
||||
|
||||
await test.step("the row opens its surface on its own sidebar route and highlights", async () => {
|
||||
await entry.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`));
|
||||
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-surface-close")).toBeVisible();
|
||||
await expect(screenTitle(page)).toHaveText("Legacy entry");
|
||||
await expectRowActive(entry, true);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("remembered sidebar routes keep resolving", async () => {
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`,
|
||||
);
|
||||
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
|
||||
await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true);
|
||||
});
|
||||
|
||||
await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => {
|
||||
const host = encodeURIComponent(getServerId());
|
||||
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`);
|
||||
await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(screenTitle(page)).toHaveText("Bot 2");
|
||||
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`);
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`));
|
||||
await expect(screenTitle(page)).toHaveText("Bot");
|
||||
});
|
||||
|
||||
await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const legacyIcon = page
|
||||
.getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const showcaseIcon = page
|
||||
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const footerIcon = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const markup = await showcaseIcon.innerHTML();
|
||||
expect(await footerIcon.innerHTML()).toBe(markup);
|
||||
expect(await legacyIcon.innerHTML()).not.toBe(markup);
|
||||
// The same registered icon the sidebar row draws.
|
||||
expect(await legacyIcon.innerHTML()).toBe(entryIcon);
|
||||
await qaScreenshot(page, "phase2-desktop-settings-sidebar");
|
||||
await qaScreenshot(
|
||||
page,
|
||||
"phase5-settings-sidebar-footer",
|
||||
page.getByTestId("sidebar-nav-section-footer"),
|
||||
);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached();
|
||||
await qaScreenshot(page, "phase2-compact-settings-sidebar");
|
||||
});
|
||||
});
|
||||
test("one item renders a group of rows, and each row opens its own screen params", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const bot1 = botRow(page, "bot-1");
|
||||
const bot2 = botRow(page, "bot-2");
|
||||
const status = botRow(page, "status");
|
||||
await expect(bot1).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await test.step("the rows and the separator render in order in the header", async () => {
|
||||
await expect(bot1).toHaveAccessibleName("Bot 1");
|
||||
await expect(bot2).toHaveAccessibleName("Bot 2");
|
||||
await expect(status).toHaveAccessibleName("Bot status");
|
||||
const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)];
|
||||
expect(second.y).toBeGreaterThan(first.y);
|
||||
// The separator adds its line and margins between Bot 2 and the status row.
|
||||
expect(third.y - (second.y + second.height)).toBeGreaterThan(8);
|
||||
expect(second.y - (first.y + first.height)).toBeLessThan(4);
|
||||
});
|
||||
|
||||
await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => {
|
||||
await bot2.click();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
await qaScreenshot(page, "phase6-desktop-sidebar-bots");
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the screen and its params", async () => {
|
||||
await page.reload();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
await qaScreenshot(page, "phase6-bot-screen");
|
||||
});
|
||||
|
||||
await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => {
|
||||
await bot1.click();
|
||||
await expectBotScreen(page, "bot-1");
|
||||
await page.goBack();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
});
|
||||
|
||||
await test.step("a popover opened from a later row anchors to that row", async () => {
|
||||
await status.click();
|
||||
await expectPopoverBeside(page, "Bot status details", status, bot1);
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => {
|
||||
await page.getByRole("button", { name: "More for Bot 2", exact: true }).click();
|
||||
await expectPopoverBeside(page, "Bot 2 options", bot2, status);
|
||||
await qaScreenshot(page, "phase6-desktop-popover-row");
|
||||
await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click();
|
||||
await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a param named like a route segment reaches the screen", async () => {
|
||||
await runCommand(page, "Open bot 3 on server x");
|
||||
await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
|
||||
// No bot named bot-3, so the title function returns its own fallback.
|
||||
await expect(screenTitle(page)).toHaveText("Bot");
|
||||
await page.reload();
|
||||
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Settings lists the group as one item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const header = page.getByTestId("sidebar-nav-section-header");
|
||||
await expect(
|
||||
header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`),
|
||||
).toHaveCount(1);
|
||||
await expect(header.getByText("Bots", { exact: true })).toHaveCount(1);
|
||||
await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0);
|
||||
await leaveSettings(page);
|
||||
});
|
||||
|
||||
await test.step("the compact sidebar shows the same group", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "bot-1")).toBeInViewport();
|
||||
await expect(botRow(page, "status")).toBeInViewport();
|
||||
await qaScreenshot(page, "phase6-compact-sidebar-bots");
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin adds and removes a footer item after setup", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
|
||||
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
|
||||
await runCommand(page, "Add bot alerts");
|
||||
await expect(alerts).toBeVisible();
|
||||
await expect(alerts).toHaveAccessibleName("Bot alerts");
|
||||
await expectFooterRow(page, alerts);
|
||||
|
||||
await runCommand(page, "Remove bot alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const released = trackReleasedSubscriptions(page);
|
||||
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runCommand(page, "Add bot alerts");
|
||||
await expect(alerts).toBeVisible();
|
||||
const first = await nextAlertsObservation(page, null);
|
||||
expect(released.has(first)).toBe(false);
|
||||
|
||||
await test.step("hiding the item in Settings closes its observation", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false);
|
||||
await expectReleased(released, first);
|
||||
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true);
|
||||
await leaveSettings(page);
|
||||
});
|
||||
|
||||
await test.step("removing the item through its remover closes its observation", async () => {
|
||||
await expect(alerts).toBeVisible();
|
||||
const second = await nextAlertsObservation(page, first);
|
||||
expect(released.has(second)).toBe(false);
|
||||
await runCommand(page, "Remove bot alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
await expectReleased(released, second);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,80 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
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,
|
||||
expectSidebarItemHidden,
|
||||
expectSidebarNavSettingsOrder,
|
||||
expectSidebarNavSettingsRow,
|
||||
expectSidebarOrder,
|
||||
expectStoredSidebarNav,
|
||||
leaveSettings,
|
||||
moveFooterItemUp,
|
||||
moveSidebarNavItemUp,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarFooterPreferences,
|
||||
seedSidebarNavPreferences,
|
||||
setFooterItemVisible,
|
||||
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);
|
||||
@@ -24,8 +86,8 @@ test.describe("Sidebar items in Appearance settings", () => {
|
||||
await test.step("the Sidebar section lists every item in the same order", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
|
||||
"About Sidebar",
|
||||
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
|
||||
"About Header",
|
||||
);
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
@@ -106,3 +168,68 @@ test.describe("Sidebar items in Appearance settings", () => {
|
||||
await expectSidebarItemHidden(page, "schedules");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`;
|
||||
const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ cleanup } = await installSidebarPlugins());
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
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 },
|
||||
{ key: "hosts", visible: false },
|
||||
{ key: "import", visible: false },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the Footer card lists only the footer rows", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName(
|
||||
"About Footer",
|
||||
);
|
||||
await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]);
|
||||
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 () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await moveFooterItemUp(page, syncKey);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
await leaveSettings(page);
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expect(
|
||||
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 () => {
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
|
||||
).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,219 +0,0 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const REPORT_ID = "fixture:fixture-account";
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
|
||||
return {
|
||||
id: REPORT_ID,
|
||||
account: { label: "Fixture account" },
|
||||
fetchedAt,
|
||||
sourceId: "fixture",
|
||||
sourceLabel: "Fixture plan",
|
||||
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 3 },
|
||||
{ id: "weekly", label: "Weekly", usedPct, headline: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "usage-composer-pill-",
|
||||
title: "Usage composer pill e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("usage composer pill", () => {
|
||||
test("opens the cached report without a request and shows when it was fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
|
||||
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
|
||||
await expect(popover.getByText("Test plan")).toBeVisible();
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes only its report from the popover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: [REPORT_ID] },
|
||||
{ forceRefresh: true, reportIds: [REPORT_ID] },
|
||||
]);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(pill).toContainText("64%");
|
||||
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
|
||||
|
||||
// Pressing closes the tooltip; it reopens on the next hover.
|
||||
await popover.getByText("Fixture plan", { exact: true }).hover();
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
expect(usage.listRequests()).toHaveLength(2);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [
|
||||
[agentEntry(42, twoHoursAgo())],
|
||||
{ error: "Usage source timed out" },
|
||||
() => [agentEntry(64, new Date().toISOString())],
|
||||
],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
const refresh = popover.getByTestId("usage-refresh");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
|
||||
"Unable to refresh usage",
|
||||
);
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
await expect(pill).toContainText("42%");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(3);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("prints the freshness on the card where there is no hover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
const requestsBeforeTurn = usage.agentRequests().length;
|
||||
const listRequestsBeforeTurn = usage.listRequests().length;
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
|
||||
|
||||
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
|
||||
expect(usage.agentRequests().at(-1)).toEqual({
|
||||
agentId: session.agentId,
|
||||
});
|
||||
await expect(pill).toContainText("42%");
|
||||
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("is hidden when the agent has no usage report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
await usage.waitForAgentRequests(1);
|
||||
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("is hidden on a host without usage sources", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSources: false,
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
|
||||
expect(usage.agentRequests()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,88 @@
|
||||
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 {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
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`) });
|
||||
}
|
||||
|
||||
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 and groups reports under their 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, {
|
||||
@@ -24,7 +96,7 @@ test.describe("usage screen", () => {
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -33,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" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -46,6 +121,8 @@ test.describe("usage screen", () => {
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
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();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
@@ -66,7 +143,7 @@ test.describe("usage screen", () => {
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
|
||||
},
|
||||
};
|
||||
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
||||
@@ -77,16 +154,16 @@ test.describe("usage screen", () => {
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
|
||||
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],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -97,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();
|
||||
|
||||
@@ -138,19 +213,146 @@ test.describe("usage screen", () => {
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
});
|
||||
|
||||
test("tells the user to update a host without usage sources", async ({ page }) => {
|
||||
test("tells the user to update a host without usage support", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
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();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +52,7 @@ test.describe("usage settings", () => {
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
planLabel: "Max",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
}),
|
||||
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);
|
||||
|
||||
@@ -82,26 +94,24 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
await showUsageAs(page, "remaining");
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
await expect(card.getByText("93% left")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh forces a fresh report", async ({ page }) => {
|
||||
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) },
|
||||
}),
|
||||
],
|
||||
],
|
||||
@@ -110,26 +120,26 @@ 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 sources and never calls it", async ({ page }) => {
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
|
||||
// Names the host: "Update Laptop to see usage".
|
||||
page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
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 {
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
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 };
|
||||
|
||||
type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator };
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let sheet and drawer animations settle so the image shows the final state.
|
||||
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, `${name}.png`);
|
||||
if (area.kind === "element") {
|
||||
await area.locator.screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
const clip = area.kind === "footer" ? await footerClip(page) : undefined;
|
||||
await page.screenshot({ path: file, clip });
|
||||
}
|
||||
|
||||
/** Includes summary rows above the fixed icon row. */
|
||||
async function footerClip(page: Page) {
|
||||
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,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
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: 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% 5h", "12% wk"]);
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
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(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
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();
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex:default").getByText("88% left"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked();
|
||||
// The row reads its window, percent and reset; the checkbox state says it is pinned.
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// 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);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
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 () => {
|
||||
const usageUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
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 expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const footer = page.getByTestId("sidebar-nav-section-footer");
|
||||
await expect(
|
||||
footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }),
|
||||
).toBeVisible();
|
||||
await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer });
|
||||
await leaveSettings(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
restoreArchivedWorkspace,
|
||||
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restores a branchless workspace so its archived agent can be used again", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `branchless-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(backingWorktree.projectKey);
|
||||
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
|
||||
|
||||
execFileSync("git", ["switch", "--detach", "main"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const created = await worktreeClient.createWorkspace({
|
||||
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
|
||||
title: "Sample workspace",
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Could not create the branchless workspace");
|
||||
}
|
||||
const workspaceId = created.workspace.id;
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
workspaceId,
|
||||
title: "Archived agent",
|
||||
});
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
|
||||
scope: "worktree",
|
||||
});
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
|
||||
|
||||
await page.getByRole("button", { name: "Restore", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
|
||||
|
||||
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
|
||||
cwd: tempRepo.path,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
expect(restoredHead).toBe(defaultHead);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toMatch(/^restored\//);
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
|
||||
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
|
||||
mkdirSync(screenshotDirectory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
|
||||
}
|
||||
|
||||
function commitRestorableChange(cwd: string): void {
|
||||
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
|
||||
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import readline from "node:readline";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" };
|
||||
const ultra = {
|
||||
id: "ultrafast",
|
||||
name: "Ultrafast",
|
||||
description: "Lowest latency, increased usage",
|
||||
};
|
||||
const models = [
|
||||
{ id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] },
|
||||
{ id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] },
|
||||
].map((model) =>
|
||||
Object.assign(model, {
|
||||
model: model.id,
|
||||
defaultReasoningEffort: "medium",
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }],
|
||||
}),
|
||||
);
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n");
|
||||
}
|
||||
function respond(method, params) {
|
||||
switch (method) {
|
||||
case "initialize":
|
||||
return {};
|
||||
case "model/list":
|
||||
return { data: models };
|
||||
case "config/read":
|
||||
return { config: { model: models[0].id, model_reasoning_effort: "medium" } };
|
||||
case "collaborationMode/list":
|
||||
return { data: [] };
|
||||
case "skills/list":
|
||||
return { data: [] };
|
||||
case "thread/list":
|
||||
case "thread/loaded/list":
|
||||
return { data: [] };
|
||||
case "thread/start":
|
||||
return { thread: { id: randomUUID() }, model: params.model };
|
||||
case "thread/resume":
|
||||
return { thread: { id: params.threadId } };
|
||||
case "thread/read":
|
||||
return { thread: { id: params.threadId, turns: [] } };
|
||||
case "turn/start": {
|
||||
const turnId = randomUUID();
|
||||
setTimeout(() => {
|
||||
send({
|
||||
method: "turn/started",
|
||||
params: { threadId: params.threadId, turn: { id: turnId } },
|
||||
});
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId,
|
||||
item: {
|
||||
id: randomUUID(),
|
||||
type: "agentMessage",
|
||||
text: "Ready to work on this project.",
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } },
|
||||
});
|
||||
}, 50);
|
||||
return { turn: { id: turnId } };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported Codex fixture request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes("--version")) {
|
||||
console.log("codex-cli 0.159.0");
|
||||
} else {
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) return;
|
||||
try {
|
||||
send({ id: request.id, result: respond(request.method, request.params ?? {}) });
|
||||
} catch (error) {
|
||||
send({ id: request.id, error: { code: -32601, message: error.message } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
context platform=android timeout=90000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-new-tab-button\"" 45000
|
||||
press "id=\"workspace-new-tab-button\""
|
||||
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
|
||||
press "id=\"workspace-new-tab-menu-terminal\""
|
||||
wait "id=\"terminal-key-esc\"" 10000
|
||||
is visible "id=\"terminal-virtual-keyboard-native-wide\""
|
||||
is visible "id=\"terminal-key-esc\""
|
||||
is visible "id=\"terminal-key-ctrl\""
|
||||
is visible "id=\"terminal-key-enter\""
|
||||
is visible "id=\"terminal-paste\""
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_%s__\\n' NATIVE_WIDE_KEY_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_NATIVE_WIDE_KEY_OK__" 10000
|
||||
press "id=\"terminal-key-ctrl\""
|
||||
type "c" --delay-ms 0
|
||||
type "printf '__AD_%s__\\n' MODIFIER_RESET_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_MODIFIER_RESET_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=ios timeout=90000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-new-tab-button\"" 30000
|
||||
press "id=\"workspace-new-tab-button\""
|
||||
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
|
||||
press "id=\"workspace-new-tab-menu-terminal\""
|
||||
wait "id=\"terminal-virtual-keyboard-native-wide\"" 10000
|
||||
is visible "id=\"terminal-key-esc\""
|
||||
is visible "id=\"terminal-key-enter\""
|
||||
is visible "id=\"terminal-paste\""
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_IOS_%s__\\n' KEY_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_IOS_KEY_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- assertNotVisible: ".*getRegistrationInfoAsync.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: workspace-new-tab-button
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: workspace-new-tab-button
|
||||
- tapOn:
|
||||
id: workspace-new-tab-menu-terminal
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: terminal-virtual-keyboard-native-wide
|
||||
timeout: 10000
|
||||
- assertVisible:
|
||||
id: terminal-key-esc
|
||||
- assertVisible:
|
||||
id: terminal-key-enter
|
||||
- assertVisible:
|
||||
id: terminal-paste
|
||||
- tapOn:
|
||||
id: terminal-paste
|
||||
- runFlow:
|
||||
when:
|
||||
visible: Allow Paste
|
||||
commands:
|
||||
- tapOn: Allow Paste
|
||||
- tapOn:
|
||||
id: terminal-key-enter
|
||||
- extendedWaitUntil:
|
||||
visible: __AD_IOS_PASTE_OK__
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,18 @@
|
||||
# Native wide terminal device flows
|
||||
|
||||
Run these against a connected task-owned tablet with the current development app, Metro bundle, and a workspace whose terminal opens successfully. The flows assert `terminal-virtual-keyboard-native-wide`, which is emitted only for a native noncompact window. That assertion makes a compact phone fail before any PTY success marker. A landscape phone that reaches the noncompact breakpoint is excluded by the explicit tablet `--device` argument; do not rely on the runner's default device.
|
||||
|
||||
Android Pixel Tablet API 36.1:
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad --platform android --device paseo-tablet-p1-api36
|
||||
```
|
||||
|
||||
iPad Pro 11-inch (M5), iOS 26.5: from the Namespace Mac checkout, prefill the simulator clipboard with the command below, then run the Maestro compatibility flow. Its `runFlow.when.visible` presses **Allow Paste** only if the iOS sheet appears; every other action and output assertion remains strict. The clipboard payload contains `PASTE_OK`, while only executed PTY output contains `__AD_IOS_PASTE_OK__`.
|
||||
|
||||
```sh
|
||||
printf '%s' "printf '__AD_IOS_%s__\\n' PASTE_OK" | xcrun simctl pbcopy E49C88C3-9A60-4715-B63A-881498D1991A
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
|
||||
```
|
||||
|
||||
The unsigned simulator build used for phase 1 raises a known `getRegistrationInfoAsync` entitlement overlay on launch. The flow dismisses only that exact development error and asserts it is gone before touching terminal controls; another overlay still fails the flow. `native-terminal-wide-keybar.ios.ad` separately proves that the wide iPad bar sends Enter to the PTY. It needs no clipboard permission. The Maestro flow above is the paste gate for both first-run and already-granted states.
|
||||
@@ -0,0 +1,12 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
|
||||
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
wait "id=\"workspace-tabs-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
|
||||
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
|
||||
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"context-window-meter\"" 20000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"context-window-meter\"" 10000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"context-window-meter\"" 20000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"context-window-meter\"" 10000
|
||||
press "id=\"context-window-meter\""
|
||||
wait "id=\"context-window-meter-tooltip\"" 10000
|
||||
press "id=\"tooltip-dismiss\""
|
||||
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,33 @@
|
||||
# Native wide menu flows
|
||||
|
||||
Run these on a connected task-owned tablet with the current development app and Metro bundle. `workspace-tabs-scroll` asserts the wide tab row; also pass an explicit tablet `--device` so a landscape phone above the breakpoint cannot stand in for tablet coverage. The context meter flow requires an agent tab with reported token usage selected. Its tooltip assertion uses a stable ID emitted only by the open content.
|
||||
|
||||
The agent tab menu flow requires an existing agent tab. Set `AGENT_ID` to the ID of that agent and `TAB_ID` to its workspace tab ID (shown in the `workspace-tab-context-*` test ID) before replay. The flow long-presses the tab and selects **Copy agent id**. It does not claim that an arrow tap works.
|
||||
|
||||
The sidebar Rename flow requires a selected workspace and its `WORKSPACE_KEY` (shown in the `sidebar-workspace-kebab-*` test ID). It opens the row's actions, opens Rename, then cancels without changing the workspace name. This covers the currently working row reported in #5226.
|
||||
|
||||
For the native-wide tooltip dismissal flows, select an agent with reported token usage so `context-window-meter` exists. A fresh load-test stream can supply usage. The Maestro flows assert that the tooltip is absent after dismissal, then reopen it to prove the control is usable. The overlay dismissal flows assert that the tab menu and Rename dialog are absent after selection/cancel. On iPad the focused Rename input can consume the first Cancel tap; the iOS Maestro flow conditionally repeats that tap only while the dialog remains visible.
|
||||
|
||||
For tab scrolling, first create enough tabs for actual horizontal overflow. The Android replay was validated with nine tabs and the Explorer sidebar open; the iPad replay was validated with six tabs. Set `DRAFT_ID` to a visible draft tab, `AGENT_ID` to the first visible agent tab, `OTHER_AGENT_ID` to another agent tab, and `TAB_ID` to the first agent's workspace tab ID. The short pan starts on an agent chip while a draft is selected; the draft composer must remain active. The flow then checks a stationary long press and an ordinary tap. Keep the tab row scrolled to its beginning before running the iPad flow.
|
||||
|
||||
Android Pixel Tablet API 36.1:
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad --platform android --device paseo-tablet-p1-api36 --env OTHER_AGENT_ID=<other-agent-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad --platform android --device paseo-tablet-p1-api36 --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id> --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad --platform android --device paseo-tablet-p1-api36 --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
```
|
||||
|
||||
iPad Pro 11-inch (M5), iOS 26.5, from the Namespace Mac checkout:
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env OTHER_AGENT_ID=<other-agent-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: workspace-tabs-scroll
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: workspace-tab-agent_${AGENT_ID}
|
||||
- longPressOn:
|
||||
id: workspace-tab-agent_${AGENT_ID}
|
||||
- assertVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- tapOn:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- assertNotVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- longPressOn:
|
||||
id: workspace-tab-agent_${AGENT_ID}
|
||||
- assertVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- tapOn:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- assertNotVisible:
|
||||
id: workspace-tab-context-${TAB_ID}-copy-agent-id
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- stopApp
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- assertNotVisible: ".*getRegistrationInfoAsync.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: workspace-tabs-scroll
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
commands:
|
||||
- tapOn:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
timeout: 10000
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
|
||||
- stopApp
|
||||
@@ -0,0 +1,13 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
|
||||
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
|
||||
wait "id=\"workspace-tabs-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,16 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
|
||||
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
|
||||
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
|
||||
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 10000
|
||||
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
|
||||
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,17 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
gesture pan 900 155 550 0 280
|
||||
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
|
||||
wait "id=\"composer-import-agent-pill\"" 10000
|
||||
gesture pan 770 155 -310 0 280
|
||||
is visible "id=\"composer-import-agent-pill\""
|
||||
gesture pan 900 155 550 0 280
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
|
||||
press "label=\"Menu backdrop\""
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,16 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-tabs-scroll\"" 20000
|
||||
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
|
||||
wait "id=\"composer-import-agent-pill\"" 10000
|
||||
gesture pan 450 86 -210 0 280
|
||||
is visible "id=\"composer-import-agent-pill\""
|
||||
gesture pan 450 86 240 0 280
|
||||
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
|
||||
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
|
||||
press "label=\"Menu backdrop\""
|
||||
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
|
||||
wait "id=\"agent-chat-scroll\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: context-window-meter
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
@@ -0,0 +1,30 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- assertNotVisible: ".*getRegistrationInfoAsync.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: context-window-meter
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: context-window-meter
|
||||
- assertVisible:
|
||||
id: context-window-meter-tooltip
|
||||
- tapOn:
|
||||
id: tooltip-dismiss
|
||||
- assertNotVisible:
|
||||
id: context-window-meter-tooltip
|
||||
@@ -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"
|
||||
@@ -0,0 +1,26 @@
|
||||
# Native iOS text geometry regression
|
||||
|
||||
Run on the Namespace Mac with a fresh simulator development build. Use the PID
|
||||
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
|
||||
Attach only to your task's app, while no device replay is running:
|
||||
|
||||
```sh
|
||||
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
|
||||
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
|
||||
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
|
||||
```
|
||||
|
||||
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
|
||||
and resizes its content container from 500 to 240 and back to 500 points. It
|
||||
asserts the inner frame follows both widths and the same selection survives.
|
||||
It uses public UIView/UITextView operations, without mocking React Native.
|
||||
LLDB detaches after the probe. An assertion failure or evaluation error fails the
|
||||
shell command; the log distinguishes them.
|
||||
|
||||
This covers native container resizing and selection retention. It does not cover
|
||||
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
|
||||
menu. Those require the real chat journey: read an older multiline response,
|
||||
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
|
||||
retain expanded/collapsed details, and check a stationary reader's position.
|
||||
Accessibility exposes full text even when glyphs are clipped, so a text wait
|
||||
alone does not verify that journey.
|
||||
@@ -0,0 +1,55 @@
|
||||
import lldb
|
||||
|
||||
|
||||
def __lldb_init_module(debugger, _internal_dict):
|
||||
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
|
||||
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
|
||||
options = lldb.SBExpressionOptions()
|
||||
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
|
||||
result = frame.EvaluateExpression(
|
||||
r'''({
|
||||
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
|
||||
auto hostFrame = host.frame;
|
||||
hostFrame.size.width = 500;
|
||||
hostFrame.size.height = 200;
|
||||
host.frame = hostFrame;
|
||||
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
|
||||
container.frame = host.bounds;
|
||||
UITextView *text = nil;
|
||||
for (UIView *child in host.subviews) {
|
||||
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
|
||||
for (UIView *nested in child.subviews) {
|
||||
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
|
||||
}
|
||||
}
|
||||
text.frame = container.bounds;
|
||||
text.font = [UIFont systemFontOfSize:16];
|
||||
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
|
||||
auto range = text.selectedRange;
|
||||
range.location = 12;
|
||||
range.length = 9;
|
||||
text.selectedRange = range;
|
||||
auto frame = container.frame;
|
||||
frame.size.width = 240;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat narrow = text.frame.size.width;
|
||||
auto selection = text.selectedRange;
|
||||
frame.size.width = 500;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat wide = text.frame.size.width;
|
||||
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
|
||||
selection.location == text.selectedRange.location &&
|
||||
selection.length == text.selectedRange.length &&
|
||||
selection.location == 12 && selection.length == 9;
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide,
|
||||
(unsigned long)text.selectedRange.location,
|
||||
(unsigned long)text.selectedRange.length];
|
||||
})''',
|
||||
options,
|
||||
)
|
||||
if result.GetError().Fail():
|
||||
raise RuntimeError(result.GetError().GetCString())
|
||||
print(result.GetObjectDescription())
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
|
||||
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
|
||||
|
||||
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
|
||||
echo "Expected a running PaseoDebug simulator app" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
|
||||
-o "command script import $probe_dir/ios.py" \
|
||||
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
|
||||
|
||||
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
@@ -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,63 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { openSettingsSection } from "./settings";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function contentWidthInput(page: Page): Locator {
|
||||
return page.getByLabel("Content width in pixels", { exact: true });
|
||||
}
|
||||
|
||||
function resetContentWidthButton(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Reset content width to default", exact: true });
|
||||
}
|
||||
|
||||
export async function openAppearanceSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
export async function setContentWidth(page: Page, width: number): Promise<void> {
|
||||
const input = contentWidthInput(page);
|
||||
await input.fill(String(width));
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
export async function resetContentWidth(page: Page): Promise<void> {
|
||||
await resetContentWidthButton(page).click();
|
||||
}
|
||||
|
||||
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
|
||||
await expect(contentWidthInput(page)).toHaveValue(String(width));
|
||||
await expect(resetContentWidthButton(page)).toHaveCount(0);
|
||||
await expect.poll(() => readStoredContentWidth(page)).toBeNull();
|
||||
}
|
||||
|
||||
export async function expectCustomContentWidthSetting(page: Page, width: number): Promise<void> {
|
||||
await expect(contentWidthInput(page)).toHaveValue(String(width));
|
||||
await expect(resetContentWidthButton(page)).toBeVisible();
|
||||
await expect.poll(() => readStoredContentWidth(page)).toBe(width);
|
||||
}
|
||||
|
||||
/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */
|
||||
export async function expectContentColumnWidth(
|
||||
element: Locator,
|
||||
bounds: { min?: number; max: number },
|
||||
): Promise<void> {
|
||||
await expect(element).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => (await element.boundingBox())?.width ?? 0)
|
||||
.toBeLessThanOrEqual(bounds.max);
|
||||
await expect
|
||||
.poll(async () => (await element.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(bounds.min ?? 0);
|
||||
}
|
||||
|
||||
async function readStoredContentWidth(page: Page): Promise<unknown> {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null
|
||||
? undefined
|
||||
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
|
||||
}, APP_SETTINGS_KEY);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -34,7 +34,7 @@ function Details({ workspaceId, theme, layout, close }) {
|
||||
React.useEffect(() => {
|
||||
const owner = paseo.observeEvents(["project.update"]);
|
||||
owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } });
|
||||
// Deliberately leave this observation to the mounted host scope.
|
||||
return () => { void owner.release(); };
|
||||
}, [paseo]);
|
||||
const rpc = useRpc(summary);
|
||||
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
|
||||
|
||||
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
export const SHOWCASE_PLUGIN_ID = "sidebar-showcase";
|
||||
export const LEGACY_PLUGIN_ID = "legacy-sidebar";
|
||||
export const BOTS_PLUGIN_ID = "sidebar-bots";
|
||||
export const WIDE = { width: 1440, height: 900 };
|
||||
export const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
/** A plugin written against the current API: a screen, header and footer items, a command. */
|
||||
const SHOWCASE_SOURCE = `import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function DeploysScreen({ theme }) {
|
||||
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Deploys screen body</Text></View>;
|
||||
}
|
||||
|
||||
function RefreshButton({ theme, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Refresh deploys" onPress={onPress} style={{ paddingHorizontal: 6 }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Refresh</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploysItem({ theme, currentScreen, openScreen }) {
|
||||
const [refreshes, setRefreshes] = React.useState(0);
|
||||
return (
|
||||
<SidebarRow
|
||||
icon="Rocket"
|
||||
label={refreshes === 0 ? undefined : "Deploys (refreshed " + refreshes + ")"}
|
||||
active={currentScreen?.screenId === "deploys"}
|
||||
onPress={() => openScreen({ screenId: "deploys" })}
|
||||
trailing={<RefreshButton theme={theme} onPress={() => setRefreshes((count) => count + 1)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncDetails({ theme, layout, close, openScreen }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Sync details</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Open deploys from popover" onPress={() => openScreen({ screenId: "deploys" })}><Text style={{ color: theme.colors.foreground }}>Open deploys</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close sync details" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncItem({ openPopover }) {
|
||||
return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("Sidebar item exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen });
|
||||
client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem });
|
||||
client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken });
|
||||
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
|
||||
client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken });
|
||||
client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* One header item that renders a group: a row per bot, a separator and a status row. Each bot
|
||||
* row opens the bot screen with the bot's id as a param, and its trailing More button opens that
|
||||
* bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item
|
||||
* after setup; that item opens an event observation, releases it in its cleanup, and publishes its
|
||||
* id on `globalThis.__botAlertsObservation`.
|
||||
*/
|
||||
const BOTS_SOURCE = `import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
const BOTS = [
|
||||
{ id: "bot-1", name: "Bot 1" },
|
||||
{ id: "bot-2", name: "Bot 2" },
|
||||
];
|
||||
|
||||
function BotScreen({ theme, params }) {
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 24, gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{"Bot screen: " + params.botId}</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>{"serverId param: " + (params.serverId ?? "none")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function botTitle(params) {
|
||||
return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot";
|
||||
}
|
||||
|
||||
const botMenus = {};
|
||||
|
||||
function botMenu(bot) {
|
||||
botMenus[bot.id] ??= function BotMenu({ theme, close }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{bot.name + " options"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={"Close " + bot.name + " options"} onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return botMenus[bot.id];
|
||||
}
|
||||
|
||||
function MoreButton({ theme, bot, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={"More for " + bot.name} onPress={onPress} style={{ paddingHorizontal: 6 }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>More</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function BotStatus({ theme, close }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Bot status details</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close bot status" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BotsItem({ theme, currentScreen, openScreen, openPopover }) {
|
||||
return (
|
||||
<>
|
||||
{BOTS.map((bot) => (
|
||||
<SidebarRow
|
||||
key={bot.id}
|
||||
id={bot.id}
|
||||
icon="Bot"
|
||||
label={bot.name}
|
||||
active={currentScreen?.screenId === "bot" && currentScreen.params.botId === bot.id}
|
||||
onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
|
||||
trailing={<MoreButton theme={theme} bot={bot} onPress={() => openPopover(botMenu(bot))} />}
|
||||
/>
|
||||
))}
|
||||
<SidebarSeparator />
|
||||
<SidebarRow id="status" icon="Activity" label="Bot status" onPress={() => openPopover(BotStatus)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsItem() {
|
||||
const paseo = usePaseo();
|
||||
React.useEffect(() => {
|
||||
const observation = paseo.observeEvents(["project.update"]);
|
||||
observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} });
|
||||
return () => { void observation.release(); };
|
||||
}, [paseo]);
|
||||
return <SidebarRow icon="Bell" onPress={() => {}} />;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
let removeAlerts = null;
|
||||
client.addScreen({ id: "bot", title: botTitle, Component: BotScreen });
|
||||
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
|
||||
client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) });
|
||||
client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => {
|
||||
if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem });
|
||||
} });
|
||||
client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => {
|
||||
removeAlerts?.();
|
||||
removeAlerts = null;
|
||||
} });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
/** A plugin that still uses only the old descriptor calls. */
|
||||
const LEGACY_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
function Main({ theme }) {
|
||||
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Legacy screen body</Text></View>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Main);
|
||||
plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" });
|
||||
plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
export async function installSidebarPlugins() {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directories: string[] = [];
|
||||
for (const [id, source] of [
|
||||
[SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE],
|
||||
[LEGACY_PLUGIN_ID, LEGACY_SOURCE],
|
||||
[BOTS_PLUGIN_ID, BOTS_SOURCE],
|
||||
] as const) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), source);
|
||||
}
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
for (const directory of directories) await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
async cleanup() {
|
||||
await client.removePlugin(SHOWCASE_PLUGIN_ID);
|
||||
await client.removePlugin(LEGACY_PLUGIN_ID);
|
||||
await client.removePlugin(BOTS_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await Promise.all(
|
||||
directories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
|
||||
export function visibleTestId(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
export function headerRow(page: Page, pluginId: string, itemId: string): Locator {
|
||||
return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`);
|
||||
}
|
||||
|
||||
export function footerItem(page: Page, pluginId: string, itemId: string): Locator {
|
||||
return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`);
|
||||
}
|
||||
|
||||
export async function expectRowActive(row: Locator, active: boolean): Promise<void> {
|
||||
if (active) await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
else await expect(row).not.toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeScreen(page: Page): Promise<void> {
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
await expect(page).not.toHaveURL(/\/plugin\//);
|
||||
}
|
||||
|
||||
/** Box of the popover body, located from the text it renders. */
|
||||
export async function popoverBox(page: Page, text = "Sync details") {
|
||||
const details = page.getByText(text, { exact: true });
|
||||
await expect(details).toBeInViewport();
|
||||
const box = await details.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -55,10 +56,25 @@ export async function seedSidebarNavPreferences(
|
||||
);
|
||||
}
|
||||
|
||||
/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */
|
||||
export async function seedSidebarFooterPreferences(
|
||||
page: Page,
|
||||
preferences: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function leaveSettings(page: Page): Promise<void> {
|
||||
@@ -125,9 +141,9 @@ export async function expectStoredSidebarNav(
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectVerticalOrder(
|
||||
keys: SidebarNavKey[],
|
||||
locate: (key: SidebarNavKey) => Locator,
|
||||
async function expectVerticalOrder<Key extends string>(
|
||||
keys: Key[],
|
||||
locate: (key: Key) => Locator,
|
||||
subject: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
@@ -136,11 +152,7 @@ async function expectVerticalOrder(
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
if (
|
||||
!measured.every(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
)
|
||||
)
|
||||
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
@@ -148,3 +160,122 @@ async function expectVerticalOrder(
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
|
||||
/** Persisted footer row key -> the testID the app shell renders that row with. */
|
||||
function shellFooterTestID(key: string): string {
|
||||
if (key === "usage") return "sidebar-usage";
|
||||
const [, pluginId, itemId] = key.split(":");
|
||||
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
|
||||
}
|
||||
|
||||
function shellFooterRow(page: Page, key: string): Locator {
|
||||
return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first();
|
||||
}
|
||||
|
||||
function footerSettingsRows(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.locator('[data-testid^="sidebar-nav-item-"]');
|
||||
}
|
||||
|
||||
export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
await footerSettingsRows(page).evaluateAll((rows) =>
|
||||
rows.map((row) => row.getAttribute("data-testid")),
|
||||
)
|
||||
).map((testID) => testID?.replace("sidebar-nav-item-", "")),
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
|
||||
export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-move-up-${key}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function setFooterItemVisible(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
|
||||
}
|
||||
|
||||
export async function expectFooterItemHidden(page: Page, key: string): Promise<void> {
|
||||
await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
),
|
||||
)
|
||||
).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,24 +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[] }>;
|
||||
agentRequests(): Array<{ agentId: string }>;
|
||||
listRequests(): UsageListRequest[];
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
waitForAgentRequests(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)>;
|
||||
/** Successive agent report IDs; the last one repeats. */
|
||||
agentReportIds?: Array<string | null>;
|
||||
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
|
||||
usageSources?: boolean;
|
||||
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 };
|
||||
@@ -42,7 +48,11 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return envelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withUsageSourcesFeature(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> };
|
||||
@@ -61,7 +71,14 @@ function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): s
|
||||
...envelope,
|
||||
message: {
|
||||
...envelope.message,
|
||||
payload: { ...payload, features: { ...features, usageSources: enabled } },
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...features,
|
||||
usageSources: enabled && !providerUsageListOnly,
|
||||
providerUsageList: enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -94,25 +111,30 @@ export async function installUsageReportsFixture(
|
||||
page: Page,
|
||||
options: UsageReportsFixtureOptions,
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
|
||||
const agentRequests: Array<{ agentId: string }> = [];
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const agentCounter = createCounter();
|
||||
const usageSources = options.usageSources ?? true;
|
||||
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({
|
||||
@@ -121,7 +143,7 @@ export async function installUsageReportsFixture(
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "usage.list_reports.request",
|
||||
requestType,
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
@@ -133,46 +155,45 @@ 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;
|
||||
}
|
||||
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
|
||||
agentRequests.push({
|
||||
agentId: String(request.agentId),
|
||||
});
|
||||
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "agent.resolve_usage_report.response",
|
||||
payload: { requestId, reportId },
|
||||
},
|
||||
}),
|
||||
);
|
||||
agentCounter.increment();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, usageSupported, providerUsageListOnly)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
agentRequests: () => [...agentRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
waitForAgentRequests: (count) => agentCounter.waitFor(count),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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");
|
||||
|
||||
function sourceIcon(plugin: string): string {
|
||||
return readFileSync(path.join(PLUGINS_DIR, plugin, "icon.svg"), "utf8");
|
||||
}
|
||||
|
||||
function inOneDay(): string {
|
||||
return new Date(Date.now() + 24 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Claude and Codex reports shaped like the built-in sources report them. */
|
||||
export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: "claude:default",
|
||||
account: { label: "dev@example.com" },
|
||||
fetchedAt,
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
icon: sourceIcon("claude-usage-source"),
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Max",
|
||||
windows: [
|
||||
{
|
||||
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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codex:default",
|
||||
account: { label: "dev@example.com" },
|
||||
fetchedAt,
|
||||
sourceId: "codex",
|
||||
sourceLabel: "Codex",
|
||||
icon: sourceIcon("codex-usage-source"),
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 7,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 12,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
|
||||
function visible(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
/** The sidebar footer's Usage item: each summary window with data. */
|
||||
export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
|
||||
/** The compact usage sheet the Usage item opens. */
|
||||
export function usageSheet(page: Page): Locator {
|
||||
return visible(page, "usage-expanded");
|
||||
}
|
||||
|
||||
export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
/** 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 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". */
|
||||
export function pinRow(scope: Locator, source: string, window: string): Locator {
|
||||
// The row's label goes on with its percent and reset: "Pin Claude Session, 31% · resets in 2h".
|
||||
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
|
||||
}
|
||||
|
||||
export async function togglePin(scope: Locator, source: string, window: string) {
|
||||
const row = pinRow(scope, source, window);
|
||||
const pinned = await row.isChecked();
|
||||
await row.click();
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
|
||||
export async function leaveUsageScreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).first().click();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -62,6 +62,8 @@ function installProbe(
|
||||
clientBundle,
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
@@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
@@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
const unrevised = {
|
||||
contentById: new Set<string>(),
|
||||
@@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -553,6 +561,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -646,6 +655,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -755,6 +765,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -860,6 +871,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -959,6 +971,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -1056,6 +1069,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -1175,6 +1189,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -1408,6 +1423,7 @@ describe("createWebStreamStrategy", () => {
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
|
||||
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
|
||||
|
||||
@@ -303,6 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
hasOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
contentMaxWidth,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
@@ -403,7 +404,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = segments.historyVirtualized[index];
|
||||
return row ? estimateStreamItemHeight(row) : 120;
|
||||
return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
rangeExtractor,
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
|
||||
contentMaxWidth: number;
|
||||
}
|
||||
|
||||
export interface ResolveStreamRenderStrategyInput {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -215,7 +214,7 @@ function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
@@ -55,7 +55,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { returnToTimelineTail } from "./timeline-tail-navigation";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -349,6 +349,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const pendingClientMessageIds = useMemo(
|
||||
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
|
||||
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
@@ -1622,7 +1624,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
contentWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
@@ -1643,7 +1645,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
@@ -123,8 +124,8 @@ describe("splitWebVirtualizedHistory", () => {
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
it("uses compact estimates for collapsed tool sequence rows", () => {
|
||||
expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
|
||||
expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
|
||||
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
});
|
||||
|
||||
it("uses a larger estimate for user messages with image attachments", () => {
|
||||
@@ -146,7 +147,7 @@ describe("estimateStreamItemHeight", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
|
||||
});
|
||||
|
||||
it("uses cached assistant image metadata when available", () => {
|
||||
@@ -165,7 +166,7 @@ describe("estimateStreamItemHeight", () => {
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ export interface WebVirtualizedHistoryWindow {
|
||||
mountedEntries: IndexedStreamItem[];
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,6 +37,7 @@ interface FakeTheme {
|
||||
"4xl": number;
|
||||
};
|
||||
lineHeight: { diff: number };
|
||||
contentMaxWidth: number;
|
||||
colors: { foreground: string; syntax: Record<string, string> };
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ function makeFakeTheme(): FakeTheme {
|
||||
"4xl": 26,
|
||||
},
|
||||
lineHeight: { diff: 22 },
|
||||
contentMaxWidth: 820,
|
||||
colors: { foreground: "#fff", syntax: {} },
|
||||
};
|
||||
}
|
||||
@@ -67,6 +69,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
|
||||
uiBaseFontSize: 14,
|
||||
contentFontSize: 15,
|
||||
codeFontSize: 12,
|
||||
contentMaxWidth: 820,
|
||||
syntaxTheme: "one",
|
||||
...overrides,
|
||||
};
|
||||
@@ -102,6 +105,12 @@ describe("applyAppearance", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("patches the content max width into the theme", () => {
|
||||
applyAppearance(makeInput({ contentMaxWidth: 1600 }));
|
||||
|
||||
expect(runCapturedUpdater().contentMaxWidth).toBe(1600);
|
||||
});
|
||||
|
||||
it("resolves an empty UI font family to the default stack", () => {
|
||||
applyAppearance(makeInput({ uiFontFamily: "" }));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AppearanceInput {
|
||||
uiBaseFontSize: number; // already clamped
|
||||
contentFontSize: number; // already clamped
|
||||
codeFontSize: number; // already clamped
|
||||
contentMaxWidth: number; // already clamped, default resolved
|
||||
syntaxTheme: SyntaxThemeId;
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ function scaleFontSize(
|
||||
* always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
|
||||
*
|
||||
* The updater preserves the active theme wholesale (surfaces, accents,
|
||||
* terminal) and only patches the font ramp and syntax palette.
|
||||
* terminal) and only patches the font ramp, content width, and syntax palette.
|
||||
* `updateTheme` replaces the stored theme rather than merging, so we spread
|
||||
* `...t` first.
|
||||
*/
|
||||
@@ -86,6 +87,7 @@ export function applyAppearance(input: AppearanceInput): void {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
contentMaxWidth: input.contentMaxWidth,
|
||||
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
|
||||
};
|
||||
}
|
||||
@@ -94,6 +96,7 @@ export function applyAppearance(input: AppearanceInput): void {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
contentMaxWidth: input.contentMaxWidth,
|
||||
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import {
|
||||
DEFAULT_THEME_PREFERENCE,
|
||||
resolveContentMaxWidth,
|
||||
useAppSettings,
|
||||
type AppSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import {
|
||||
rememberPluginThemeHost,
|
||||
usePluginThemeCatalog,
|
||||
@@ -68,6 +73,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
uiBaseFontSize: settings.uiBaseFontSize,
|
||||
contentFontSize: settings.contentFontSize,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
contentMaxWidth: resolveContentMaxWidth({ contentMaxWidth: settings.contentMaxWidth }),
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
setHasAppliedAppearance(true);
|
||||
@@ -80,6 +86,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
settings.uiBaseFontSize,
|
||||
settings.contentFontSize,
|
||||
settings.codeFontSize,
|
||||
settings.contentMaxWidth,
|
||||
settings.syntaxTheme,
|
||||
]);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PlanModeIcon,
|
||||
ThinkingIcon,
|
||||
} from "@/agent-controls/icons";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcons } from "@/components/provider-icons";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import {
|
||||
buildAgentControlContributions,
|
||||
@@ -88,6 +88,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
const { t } = useTranslation();
|
||||
const { controls } = input;
|
||||
const { features, models, modes, thinking } = controls;
|
||||
const getProviderIcon = useProviderIcons(controls.serverId);
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
buildAgentControlContributions({
|
||||
@@ -96,8 +97,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
provider: controls.provider ?? null,
|
||||
labels: buildAgentControlContributionLabels(t),
|
||||
icons: {
|
||||
provider: (provider) =>
|
||||
getCommandCenterIcon(getProviderIcon(provider, controls.serverId)),
|
||||
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
|
||||
thinking: getCommandCenterIcon(ThinkingIcon),
|
||||
planMode: getCommandCenterIcon(PlanModeIcon),
|
||||
mode: (modeId) =>
|
||||
@@ -129,6 +129,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
},
|
||||
}),
|
||||
[
|
||||
getProviderIcon,
|
||||
controls.ownerKey,
|
||||
controls.provider,
|
||||
controls.providerDefinitions,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
@@ -186,7 +186,7 @@ function SessionRow({
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider, agent.serverId);
|
||||
const ProviderIcon = useProviderIcon(agent.provider, agent.serverId);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const ranges = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
inputAreaContent: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
},
|
||||
callout: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -187,7 +187,7 @@ export function ContextWindowMeter({
|
||||
) : null}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<TooltipContent side="top" align="center" offset={8} testID="context-window-meter-tooltip">
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
|
||||
<Text style={styles.tooltipText}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,8 @@ vi.mock("@/constants/layout", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: () => () => null,
|
||||
useProviderIcon: () => () => null,
|
||||
useProviderIcons: () => () => () => null,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -309,7 +309,7 @@ function ImportSessionSheetRow({
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const ProviderIcon = useProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
[disabled],
|
||||
@@ -559,15 +559,17 @@ export function ImportSessionSheet({
|
||||
setIsFilterOpen(false);
|
||||
}, []);
|
||||
|
||||
const getProviderIcon = useProviderIcons(serverId);
|
||||
const SelectedProviderIcon = useProviderIcon(selectedProvider, serverId);
|
||||
const filterOptionIcons = useMemo(() => {
|
||||
const map = new Map<string, React.ReactNode>();
|
||||
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
|
||||
for (const provider of filterProviders) {
|
||||
const ProviderIcon = getProviderIcon(provider, serverId);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
|
||||
}
|
||||
return map;
|
||||
}, [filterProviders, serverId, theme.colors.foregroundMuted]);
|
||||
}, [filterProviders, getProviderIcon, theme.colors.foregroundMuted]);
|
||||
|
||||
const renderFilterOption = useCallback(
|
||||
({
|
||||
@@ -746,10 +748,7 @@ export function ImportSessionSheet({
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
(() => {
|
||||
const ProviderIcon = getProviderIcon(selectedProvider, serverId);
|
||||
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
|
||||
})()
|
||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, Gauge, 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";
|
||||
@@ -43,16 +43,16 @@ import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"
|
||||
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";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsRoute,
|
||||
buildUsageRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
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";
|
||||
@@ -89,9 +89,9 @@ interface SidebarSharedProps {
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
usage: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
@@ -196,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],
|
||||
@@ -283,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;
|
||||
@@ -310,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>
|
||||
@@ -423,32 +366,14 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
|
||||
const { t } = useTranslation();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const handlePress = useCallback(() => {
|
||||
if (isCompactLayout) showMobileAgent();
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompactLayout, showMobileAgent]);
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={handlePress}
|
||||
testID="sidebar-usage"
|
||||
label={t("settings.hostSections.usage")}
|
||||
icon={Gauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
@@ -467,52 +392,57 @@ function IconTooltipContent({
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
handleImportSession,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
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;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
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.footerIconRow}>
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<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}
|
||||
/>
|
||||
<SidebarUsageButton theme={theme} />
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
@@ -527,6 +457,40 @@ function SidebarFooter({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 && (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>
|
||||
<SidebarSeparator testID="sidebar-footer-separator" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebar({
|
||||
active,
|
||||
theme,
|
||||
@@ -580,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}
|
||||
@@ -629,11 +597,11 @@ function MobileSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
</View>
|
||||
</MobilePanelOverlay>
|
||||
@@ -806,7 +774,6 @@ function DesktopSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -905,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.
|
||||
@@ -953,44 +919,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
footerContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
footerIconRow: {
|
||||
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerAddProjectButton: {
|
||||
minWidth: 0,
|
||||
minHeight: 32,
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
},
|
||||
footerAddProjectButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
// Pushes Help and Settings to the end of the icon line.
|
||||
footerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
footerAddProjectLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerAddProjectLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
// Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows.
|
||||
footerRows: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user