mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c2bbac1a0 | ||
|
|
8a4abfedfb | ||
|
|
8ced059b4d | ||
|
|
da9fae172d | ||
|
|
f24bb523e8 | ||
|
|
cf2b3f4bb3 | ||
|
|
a50e14b69e | ||
|
|
7b43f21940 | ||
|
|
2f9a786061 | ||
|
|
6b00310f7b | ||
|
|
981bb4c409 | ||
|
|
3ee4c49e3c | ||
|
|
fbd1bc47e0 | ||
|
|
36e6ccd6c7 | ||
|
|
fa0f04058d | ||
|
|
d517b3314e | ||
|
|
a8e6c57f15 | ||
|
|
1f8a4559a8 | ||
|
|
2187124642 | ||
|
|
154a088d6e | ||
|
|
71b236d38a | ||
|
|
8a4ce5a482 | ||
|
|
b3ed7363c6 | ||
|
|
c062280429 | ||
|
|
febe9611bf | ||
|
|
642d69b142 | ||
|
|
5aee41952e | ||
|
|
48329facc3 | ||
|
|
97083dd736 | ||
|
|
5c2c85ebc8 | ||
|
|
30fb66c8cb | ||
|
|
05b074764d | ||
|
|
5285b7e502 | ||
|
|
409b8f9312 | ||
|
|
485221b739 | ||
|
|
ed48655a77 | ||
|
|
cc8fe41e28 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 |
+66
-60
@@ -1,63 +1,40 @@
|
||||
# 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
|
||||
## 0.11.0-beta.4 - 2026-10-05
|
||||
|
||||
### 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 Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#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), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- 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))
|
||||
- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- 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 a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- 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))
|
||||
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090))
|
||||
- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087))
|
||||
- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044))
|
||||
- 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))
|
||||
@@ -65,19 +42,32 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running ([#6067](https://github.com/getpaseo/paseo/pull/6067))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- 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 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 OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#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 and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- 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 the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- 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 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))
|
||||
@@ -88,23 +78,39 @@
|
||||
- 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 the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- 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 the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
|
||||
- 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 iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
- 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 browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||
- 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 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 `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053))
|
||||
- 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 `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009))
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- 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 reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
|
||||
|
||||
## 0.10.3 - 2026-10-02
|
||||
|
||||
@@ -11,7 +11,7 @@ canonical files and are trying to add or change one.
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
@@ -92,7 +92,7 @@ portal can cover them.
|
||||
Painting and keyboard ownership use the same relative layer model. Register
|
||||
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
|
||||
highest painted scope alone receives overlay keys, traps focus, and restores
|
||||
focus when it closes. Do not add component-local global Escape listeners: two
|
||||
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
|
||||
stacked overlays would both close on one keypress.
|
||||
|
||||
If an overlay is rendered by a global host rather than beneath its opener in
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **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. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **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). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage 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. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **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 remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. 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`).
|
||||
|
||||
+2
-2
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
@@ -150,5 +150,5 @@ Before opening a PR that touches hover:
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
|
||||
+2
-2
@@ -197,7 +197,7 @@ its own.
|
||||
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
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
|
||||
positioning bugs there, not at a call site.
|
||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||
|
||||
@@ -32,8 +32,10 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
||||
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
||||
|
||||
|
||||
@@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row
|
||||
IDs. Choose one row near the top and one initially below the viewport. Replace
|
||||
the quoted example values below with those IDs. Start in a workspace with the
|
||||
sidebar closed and keep the list stable during the run. The same flow checks
|
||||
reopening by swipe and button:
|
||||
|
||||
```bash
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \
|
||||
--maestro --platform android --serial emulator-5556 --metro-port 8099 \
|
||||
--env TOP_WORKSPACE='server-id:top-workspace-id' \
|
||||
--env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id'
|
||||
```
|
||||
|
||||
Pass this checkout's Metro port so the debug app uses the intended bundle.
|
||||
For iOS, compare the scrolled row's screen bounds and screenshots across reopening.
|
||||
Agent Device's iOS visibility lookup includes offscreen scroll descendants, so
|
||||
its `assertNotVisible` cannot establish the starting scroll position for this flow.
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
+10
-5
@@ -117,7 +117,12 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
||||
|
||||
## Install from a registry
|
||||
|
||||
`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs
|
||||
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
|
||||
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
|
||||
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
|
||||
|
||||
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
|
||||
artifact, and GitHub shorthand requires `github:`. Registry installs
|
||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||
|
||||
@@ -133,13 +138,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
|
||||
still wins over source resolution.
|
||||
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL. An existing directory still wins over source resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin install github:owner/repository
|
||||
paseo plugin install owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install github:owner/monorepo:plugins/review
|
||||
paseo plugin install owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
|
||||
+15
-17
@@ -95,7 +95,7 @@ release push as the changelog and version commit.
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
@@ -194,7 +194,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
@@ -414,7 +414,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -521,17 +521,20 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series.
|
||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
|
||||
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
|
||||
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
|
||||
- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry.
|
||||
- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets.
|
||||
- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry.
|
||||
- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review.
|
||||
|
||||
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog wording
|
||||
|
||||
@@ -563,7 +566,7 @@ that changed, the failure that was fixed, or the capability that was added? If t
|
||||
claims a general improvement, rewrite it with the concrete change.
|
||||
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -631,12 +634,7 @@ Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. T
|
||||
|
||||
Changelog scope follows the release being described:
|
||||
|
||||
- **First beta**: `previous stable tag → release source`
|
||||
- **Later beta**: `previous beta tag → release source`
|
||||
- **Direct stable release**: `previous stable tag → release source`
|
||||
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
|
||||
|
||||
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
|
||||
Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
@@ -644,7 +642,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
@@ -667,7 +665,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
|
||||
@@ -51,7 +51,7 @@ Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0=
|
||||
|
||||
Generated
+30
-30
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36413,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37496,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/server": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/server": "0.11.0-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38006,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38931,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38948,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@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",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/highlight": "0.11.0-beta.4",
|
||||
"@getpaseo/plugin": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,11 +40146,12 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
@@ -40165,8 +40166,7 @@
|
||||
"remark-directive": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"wrangler": "^4.75.0",
|
||||
"@getpaseo/protocol": "*"
|
||||
"wrangler": "^4.75.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -41753,7 +41753,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
@@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
|
||||
@@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo
|
||||
}
|
||||
}
|
||||
|
||||
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
|
||||
test("opens and refocuses Find with Control+f from the composer on Linux", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// Chromium retains the host navigator.platform even when its user agent is overridden.
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await session.send("Emulation.setUserAgentOverride", {
|
||||
userAgent:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
platform: "Linux x86_64",
|
||||
});
|
||||
await findFromComposer(page, "Control+f", testInfo);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerNotFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
@@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-open-",
|
||||
title: "Composer focus on open",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
|
||||
await page.evaluate(async (frames) => {
|
||||
for (let frame = 0; frame < frames; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}, count);
|
||||
}
|
||||
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-touch-",
|
||||
title: "Composer focus on touch",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
expect(
|
||||
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||
).toBe(false);
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
submitMessageWithButton,
|
||||
} from "../support/helpers/composer";
|
||||
import {
|
||||
type AgentUsageScript,
|
||||
closeContextWindowSheet,
|
||||
contextWindowSheet,
|
||||
contextWindowDetails,
|
||||
expectRefreshButtons,
|
||||
expiredLogin,
|
||||
gate,
|
||||
hoverContextWindowMeter,
|
||||
leaveContextWindowMeter,
|
||||
type MockAgentSession,
|
||||
moveOntoContextWindowDetails,
|
||||
onPersonalLogin,
|
||||
onWorkLogin,
|
||||
openAgent,
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
|
||||
import { installLoginUsage } from "../support/helpers/usage-login";
|
||||
|
||||
const test = base.extend<{ agent: MockAgentSession }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
@@ -107,6 +113,66 @@ const DESKTOP = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
|
||||
|
||||
for (const layout of [
|
||||
{
|
||||
name: "desktop",
|
||||
viewport: DESKTOP,
|
||||
open: hoverContextWindowMeter,
|
||||
close: leaveContextWindowMeter,
|
||||
},
|
||||
{
|
||||
name: "compact",
|
||||
viewport: COMPACT,
|
||||
open: pressContextWindowMeter,
|
||||
close: closeContextWindowSheet,
|
||||
},
|
||||
]) {
|
||||
test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const source = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 31 }],
|
||||
});
|
||||
try {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-empty-",
|
||||
title: "Agent before its first turn",
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize(layout.viewport);
|
||||
await openAgent(page, agent);
|
||||
|
||||
await test.step("the empty meter opens context details and account usage", async () => {
|
||||
const details = await layout.open(page, "Context window: No context data");
|
||||
await expect(details.getByText("No context data", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("0% used", { exact: true })).toHaveCount(0);
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
|
||||
await test.step("the first context reading replaces the empty state", async () => {
|
||||
await submitMessageWithButton(page, "emit 32000 byte file agent stream payload");
|
||||
const details = await layout.open(page);
|
||||
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type OpenDetails = (page: Page) => Promise<Locator>;
|
||||
type Shot = (state: string) => Promise<void>;
|
||||
|
||||
@@ -142,7 +208,6 @@ async function expectCardsToStreamIn(
|
||||
// Only the agent's login, never the host's default one, and no pins.
|
||||
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
|
||||
await expectUnpinnableRows(details);
|
||||
await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2);
|
||||
return details;
|
||||
}
|
||||
|
||||
@@ -177,24 +242,43 @@ async function expectOnlyContextWindowWithoutUsage(
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => {
|
||||
test(`the context window hover card shows the agent's usage (${theme})`, async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`);
|
||||
|
||||
await test.step("reports stream in one card at a time, without Refresh", async () => {
|
||||
const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
|
||||
// The tooltip cannot be pressed, so its cards have no Refresh.
|
||||
await expectRefreshButtons(tooltip, 0);
|
||||
await test.step("reports stream in one card at a time, each with Refresh", async () => {
|
||||
const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
|
||||
await expectRefreshButtons(card, 2);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("the card stays open while the pointer moves onto it", async () => {
|
||||
const card = contextWindowDetails(page);
|
||||
await moveOntoContextWindowDetails(page, card);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
});
|
||||
|
||||
await test.step("after a resume under another login, only that login shows", async () => {
|
||||
// Refresh can retain focus; return to the composer before testing pointer dismissal.
|
||||
await composerLocator(page).focus();
|
||||
// Closed while the old login's report is still on its way.
|
||||
await leaveContextWindowMeter(page);
|
||||
const slowSource = gate();
|
||||
@@ -208,25 +292,25 @@ for (const theme of ["light", "dark"] as const) {
|
||||
usage.answerNext({
|
||||
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
|
||||
});
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
|
||||
// The old login's report lands during the new request, before the new login's.
|
||||
slowSource.open();
|
||||
resumed.open();
|
||||
await expect(
|
||||
usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }),
|
||||
usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
finished.open();
|
||||
});
|
||||
|
||||
await test.step("a report with a problem shows it on the card", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
await reloadAgent(page);
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
@@ -256,13 +340,15 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await test.step("reports stream in one card at a time, each with Refresh", async () => {
|
||||
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
|
||||
await expectRefreshButtons(sheet, 2);
|
||||
// Without hover, each card prints its freshness instead of a Refresh tooltip.
|
||||
await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
const sheet = contextWindowSheet(page);
|
||||
const sheet = contextWindowDetails(page);
|
||||
await refreshUsageCard(sheet, "Claude");
|
||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
@@ -283,3 +369,81 @@ for (const theme of ["light", "dark"] as const) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("context details support keyboard entry and dismiss when the viewport moves", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
|
||||
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stop).toBeVisible();
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await expect(trigger).toHaveAttribute("tabindex", "0");
|
||||
await trigger.focus();
|
||||
const details = contextWindowDetails(page);
|
||||
await expect(details).toBeVisible();
|
||||
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible();
|
||||
await trigger.press("ArrowDown");
|
||||
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(details).toHaveCount(0);
|
||||
await expect(stop).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
for (const key of ["Enter", " "]) {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
await trigger.press(key);
|
||||
await expect(details).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(details).toHaveCount(0);
|
||||
await expect(stop).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
await trigger.press("ArrowDown");
|
||||
await expect(details).toBeVisible();
|
||||
await page.setViewportSize({ width: 1280, height: 850 });
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
test("Find remains available after dismissing context details", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -96,6 +96,36 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
title: "Relative line references",
|
||||
initialPrompt: "Show both references",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "src"));
|
||||
await writeFile(
|
||||
path.join(session.cwd, "src/router.go"),
|
||||
Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"),
|
||||
);
|
||||
await openAgentRoute(page, session);
|
||||
for (const line of [46, 403, 46]) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText(`src/router.go:${line}`, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible();
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath("relative-line-target.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as
|
||||
test.describe("compact sidebar help", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFingerSizedFooterTargets(page);
|
||||
await tapHelpNearTargetEdge(page);
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
|
||||
test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
@@ -165,3 +177,26 @@ test.describe("compact sidebar help", () => {
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
});
|
||||
|
||||
async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
|
||||
await expect(buttons).toHaveCount(5);
|
||||
for (const button of await buttons.all()) {
|
||||
const bounds = await button.boundingBox();
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(44);
|
||||
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
|
||||
const glyphWidth = await button.locator("svg").first().getAttribute("width");
|
||||
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
|
||||
}
|
||||
}
|
||||
|
||||
async function tapHelpNearTargetEdge(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Help and support", exact: true });
|
||||
await button.tap({ position: { x: 40, y: 40 } });
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(workspaceRow(page)).toBeVisible();
|
||||
await toggleTrailing(page, "Diff stats", true);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
|
||||
}
|
||||
@@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
const row = workspaceRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await toggleTrailing(page, "Diff stats");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
|
||||
return titleWidth(row);
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
expectPinnedUsage,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
expectUsageOptionsFitContent,
|
||||
showUsageAs,
|
||||
refreshAllUsage,
|
||||
togglePin,
|
||||
@@ -243,7 +245,7 @@ test.describe("usage screen", () => {
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
@@ -426,7 +428,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
desktop: { width: 1440, height: 900 },
|
||||
compact: { width: 390, height: 844 },
|
||||
})) {
|
||||
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
|
||||
test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript((value) => {
|
||||
const key = "@paseo:app-settings";
|
||||
@@ -440,42 +442,24 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await page.goto("/usage");
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const settings = screen.getByRole("button", { name: "Settings", exact: true });
|
||||
const chevron = settings.locator("svg").locator("../..");
|
||||
const settings = page.getByTestId("usage-options-menu");
|
||||
await expect(settings).toHaveAccessibleName("Settings");
|
||||
await expect(settings).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(chevron).toHaveCSS("transform", "none");
|
||||
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(
|
||||
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"),
|
||||
).toHaveCSS("opacity", "1");
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
|
||||
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await page.mouse.move(0, viewport.height - 1);
|
||||
await expect(
|
||||
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"),
|
||||
).toHaveCSS("opacity", "1");
|
||||
await openUsageOptions(page);
|
||||
await expect(settings).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)");
|
||||
await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
|
||||
await expect(screen.getByTestId("usage-display-used")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await settings.blur();
|
||||
await page.mouse.move(0, viewport.height - 1);
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`);
|
||||
await expectUsageOptionsFitContent(page);
|
||||
await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true");
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-settings`);
|
||||
await showUsageAs(page, "remaining");
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
);
|
||||
await settings.click();
|
||||
await expect(settings).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
|
||||
});
|
||||
|
||||
@@ -95,8 +95,8 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
// Percentages now live on the Usage screen, and still apply to the host section.
|
||||
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
// The shared percentages setting applies to the host section.
|
||||
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
||||
const hostUsageUrl = page.url();
|
||||
await page.goto("/usage");
|
||||
await showUsageAs(page, "remaining");
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsageSheet,
|
||||
closeUsageOptions,
|
||||
openUsageSheetFromIcon,
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
@@ -62,6 +65,33 @@ async function footerClip(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
const startingUrl = page.url();
|
||||
await expectNoUsageItem(page);
|
||||
|
||||
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
||||
await openUsageSheetFromIcon(page);
|
||||
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-icon-sheet");
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await closeUsageSheet(page);
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await usageItem(page).click();
|
||||
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
await closeUsageSheet(page);
|
||||
});
|
||||
});
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -189,25 +219,22 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the same inline Settings row and title-row Refresh all.
|
||||
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
// Settings opens a second sheet; closing it restores the Usage sheet underneath.
|
||||
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await openUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "compact-settings-expanded");
|
||||
await sheet.getByTestId("usage-options-toggle").click();
|
||||
await closeUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await waitForSettledPosition(sheet);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
// Settings adds a row; the sheet still reserves the top 20% as its backdrop.
|
||||
// The Usage sheet reserves the top 20% as its backdrop.
|
||||
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
@@ -229,7 +256,7 @@ test.describe("Usage item", () => {
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "usage-settings-expanded");
|
||||
await page.getByTestId("usage-options-toggle").click();
|
||||
await closeUsageOptions(page);
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
|
||||
@@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-invalid-config-", {
|
||||
files: [{ path: "paseo.json", content: "{ invalid json\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-invalid-config",
|
||||
});
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.error).toContain("Failed to parse paseo.json");
|
||||
expect(failedPayload.detail.commands).toEqual([]);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible();
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "paseo.json");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow: ../../../maestro/sidebar-scroll-retention.yaml
|
||||
@@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectComposerNotFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -59,6 +63,12 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
/** The Send button works on compact screens, where Enter inserts a newline. */
|
||||
export async function submitMessageWithButton(page: Page, text: string): Promise<void> {
|
||||
await fillComposerDraft(page, text);
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
@@ -26,38 +26,50 @@ export async function openAgent(page: Page, session: MockAgentSession): Promise<
|
||||
}
|
||||
|
||||
export async function reloadAgent(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
// Reload must not reopen the card under a stationary pointer.
|
||||
await page.mouse.move(0, 0);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
/** Wide screens show the meter's details in a tooltip while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 });
|
||||
const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" });
|
||||
await expect(tooltip).toBeVisible();
|
||||
return tooltip;
|
||||
/** Wide screens show the meter's details in a hover card while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toBeInViewport();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
const box = await card.boundingBox();
|
||||
if (!meter || !box) throw new Error("The meter and its hover card must be on screen.");
|
||||
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0);
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowSheet(page);
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
const sheet = contextWindowSheet(page);
|
||||
const sheet = contextWindowDetails(page);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function contextWindowSheet(page: Page): Locator {
|
||||
/** The hover card on wide screens and the sheet on compact ones are both this dialog. */
|
||||
export function contextWindowDetails(page: Page): Locator {
|
||||
return page.getByRole("dialog", { name: "Context window" });
|
||||
}
|
||||
|
||||
@@ -159,7 +171,7 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
export async function qaScreenshot(page: Page, name: string): Promise<void> {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let the sheet and tooltip animations settle.
|
||||
// Let the sheet and hover card animations settle.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
|
||||
export async function installTallUsageSource() {
|
||||
@@ -194,6 +195,21 @@ export async function openUsageScreenFromIcon(page: Page): Promise<void> {
|
||||
await expectOnUsageScreen(page);
|
||||
}
|
||||
|
||||
/** Both footer entry points open Usage over the current screen on a phone. */
|
||||
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click();
|
||||
await expect(usageSheet(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeUsageSheet(page: Page): Promise<void> {
|
||||
const sheet = usageSheet(page);
|
||||
await waitForSettledPosition(sheet);
|
||||
const bounds = await sheet.boundingBox();
|
||||
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
|
||||
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
function summaryInSidebarSwitch(page: Page): Locator {
|
||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
||||
}
|
||||
@@ -208,15 +224,39 @@ export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void
|
||||
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
|
||||
await closeUsageOptions(page);
|
||||
}
|
||||
|
||||
/** Expand the inline Settings row when its controls are folded. */
|
||||
/** Open the cog's settings popover or compact sheet. */
|
||||
export async function openUsageOptions(page: Page): Promise<void> {
|
||||
const toggle = visible(page, "usage-options-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
|
||||
if (!(await visible(page, "usage-display-used").isVisible())) {
|
||||
await visible(page, "usage-options-menu").click();
|
||||
}
|
||||
await expect(visible(page, "usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
export function usageOptionsSurface(page: Page): Locator {
|
||||
return page
|
||||
.locator(
|
||||
'[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible',
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function expectUsageOptionsFitContent(page: Page): Promise<void> {
|
||||
await waitForSettledPosition(usageOptionsSurface(page));
|
||||
const surface = await usageOptionsSurface(page).boundingBox();
|
||||
const fields = await page.getByTestId("usage-options-fields").boundingBox();
|
||||
if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit.");
|
||||
expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10);
|
||||
}
|
||||
|
||||
export async function closeUsageOptions(page: Page): Promise<void> {
|
||||
await waitForSettledPosition(usageOptionsSurface(page));
|
||||
await page.mouse.click(0, 0);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
await openUsageOptions(page);
|
||||
await visible(page, `usage-display-${displayAs}`).click();
|
||||
@@ -224,6 +264,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await closeUsageOptions(page);
|
||||
}
|
||||
|
||||
export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
# Start in a workspace with the sidebar closed. Both workspace IDs come from
|
||||
# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts
|
||||
# below the viewport. The list must remain unchanged throughout the flow.
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
direction: UP
|
||||
timeout: 60000
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
direction: DOWN
|
||||
timeout: 60000
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- assertNotVisible:
|
||||
id: sidebar-project-workspace-list-scroll
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "90%,50%"
|
||||
duration: 300
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
|
||||
# Selecting the current workspace removes workspace switching as a cause.
|
||||
# Reopening by button removes horizontal movement as a cause.
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-close
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +179,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
token: capturedResolution.token,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,14 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
input.ambiguousQuery,
|
||||
];
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { Eye, EyeOff, Terminal } from "lucide-react-native";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import {
|
||||
CONTROL_HEIGHTS,
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
passwordRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
width: "100%",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
passwordInputWrap: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
// Boxed adornment next to the field: same height and radius as the field
|
||||
// chrome of the active form size, and the shared interaction phases.
|
||||
iconButtonSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
width: CONTROL_HEIGHTS.compact,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field,
|
||||
width: CONTROL_HEIGHTS.field,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
controlRest: {
|
||||
...geometry.controlRest,
|
||||
},
|
||||
controlHover: {
|
||||
...geometry.controlHover,
|
||||
},
|
||||
controlActive: {
|
||||
...geometry.controlActive,
|
||||
},
|
||||
controlDisabled: {
|
||||
...geometry.controlDisabled,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export interface AddRemoteSshHostModalProps {
|
||||
visible: boolean;
|
||||
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||
const targetRef = useRef("");
|
||||
const passwordRef = useRef("");
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const passwordInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
targetRef.current = "";
|
||||
passwordRef.current = "";
|
||||
inputRef.current?.replaceText("");
|
||||
passwordInputRef.current?.replaceText("");
|
||||
setIsPasswordVisible(false);
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
|
||||
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
result = await probeAndUpsertRemoteSshConnection(target);
|
||||
result = await probeAndUpsertRemoteSshConnection({
|
||||
...target,
|
||||
password: passwordRef.current === "" ? undefined : passwordRef.current,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof DaemonConnectionTestError
|
||||
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
}, []);
|
||||
const handlePasswordChange = useCallback((value: string) => {
|
||||
passwordRef.current = value;
|
||||
}, []);
|
||||
const handleTogglePasswordVisibility = useCallback(() => {
|
||||
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
|
||||
}, []);
|
||||
const iconButtonStyle = useCallback(
|
||||
(
|
||||
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
|
||||
): StyleProp<ViewStyle> => [
|
||||
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
controlHover: styles.controlHover,
|
||||
controlActive: styles.controlActive,
|
||||
controlDisabled: styles.controlDisabled,
|
||||
},
|
||||
{
|
||||
hovered: state.hovered,
|
||||
pressed: state.pressed,
|
||||
focused: state.focused,
|
||||
disabled: isSaving,
|
||||
},
|
||||
),
|
||||
],
|
||||
[isCompact, isSaving],
|
||||
);
|
||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||
|
||||
return (
|
||||
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
|
||||
<View style={styles.passwordRow}>
|
||||
<View style={styles.passwordInputWrap}>
|
||||
<FormTextInput
|
||||
ref={passwordInputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-password-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
|
||||
initialValue=""
|
||||
onChangeText={handlePasswordChange}
|
||||
placeholder={t("pairing.remoteSsh.fields.optional")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={iconButtonStyle}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPasswordVisible
|
||||
? t("pairing.remoteSsh.passwordVisibility.hide")
|
||||
: t("pairing.remoteSsh.passwordVisibility.show")
|
||||
}
|
||||
testID="remote-ssh-password-visibility-toggle"
|
||||
>
|
||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
|
||||
@@ -7,26 +7,26 @@ import { formatTokenCount } from "./context-window-meter.utils";
|
||||
interface ContextWindowDetailsProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
percentage: number;
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
context: {
|
||||
percentage: number;
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
} | null;
|
||||
sessionCost: string | null;
|
||||
/** The tooltip titles itself; the sheet's header carries the title instead. */
|
||||
/** The hover card and tooltip title themselves; the sheet's header carries the title instead. */
|
||||
showTitle: boolean;
|
||||
/** Only the sheet can be pressed, so only its usage cards have a Refresh button. */
|
||||
/** Whether the usage cards have a Refresh button; without one they show their freshness. */
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the context window meter opens: how full the window is, then the usage of the agent's
|
||||
* account. The wide tooltip and the compact sheet both render it.
|
||||
* account. The desktop hover card, the native wide tooltip, and the compact sheet all render it.
|
||||
*/
|
||||
export function ContextWindowDetails({
|
||||
serverId,
|
||||
agentId,
|
||||
percentage,
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
context,
|
||||
sessionCost,
|
||||
showTitle,
|
||||
refreshable,
|
||||
@@ -36,13 +36,21 @@ export function ContextWindowDetails({
|
||||
<>
|
||||
<View style={styles.summary}>
|
||||
{showTitle ? <Text style={styles.title}>{t("contextWindow.title")}</Text> : null}
|
||||
<Text style={styles.text}>{t("contextWindow.used", { percentage })}</Text>
|
||||
<Text style={styles.detail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{context ? (
|
||||
<>
|
||||
<Text style={styles.text}>
|
||||
{t("contextWindow.used", { percentage: context.percentage })}
|
||||
</Text>
|
||||
<Text style={styles.detail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(context.usedTokens),
|
||||
max: formatTokenCount(context.maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.detail}>{t("contextWindow.noData")}</Text>
|
||||
)}
|
||||
{sessionCost ? (
|
||||
<Text style={styles.detail}>{t("contextWindow.sessionCost", { cost: sessionCost })}</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
|
||||
@@ -16,19 +19,16 @@ interface ContextWindowMeterProps {
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
/** Reserve the meter footprint and show a loading ring while usage is pending. */
|
||||
pending?: boolean;
|
||||
/** Optional glyph envelope for icon-toolbar alignment. */
|
||||
glyphSize?: number;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 14;
|
||||
const USAGE_POPOVER_WIDTH = 300;
|
||||
const COMPACT_SVG_SIZE = 12;
|
||||
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
|
||||
const COMPACT_RADIUS = 5;
|
||||
const STROKE_WIDTH = 2;
|
||||
const COMPACT_STROKE_WIDTH = 1.75;
|
||||
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
@@ -59,28 +59,22 @@ function formatSessionCost(value: number): string | null {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
function getProgressColor(percentage: number, theme: Theme): string {
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
return theme.colors.destructive;
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
return theme.colors.palette.amber[500];
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
if (showPercentage) {
|
||||
return {
|
||||
svgSize: COMPACT_SVG_SIZE,
|
||||
center: COMPACT_CENTER,
|
||||
radius: COMPACT_RADIUS,
|
||||
strokeWidth: COMPACT_STROKE_WIDTH,
|
||||
circumference: COMPACT_CIRCUMFERENCE,
|
||||
containerStyle: styles.containerWithLabel,
|
||||
};
|
||||
}
|
||||
@@ -88,14 +82,65 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
|
||||
return {
|
||||
svgSize: resolvedSize,
|
||||
center: resolvedSize / 2,
|
||||
radius: (resolvedSize - resolvedStrokeWidth) / 2,
|
||||
strokeWidth: resolvedStrokeWidth,
|
||||
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
|
||||
containerStyle: styles.container,
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG.
|
||||
const ContextWindowRing = withUnistyles(function ContextWindowRing({
|
||||
size,
|
||||
radius,
|
||||
strokeWidth,
|
||||
percentage,
|
||||
trackColor,
|
||||
progressColor,
|
||||
}: {
|
||||
size: number;
|
||||
radius: number;
|
||||
strokeWidth: number;
|
||||
percentage: number | null;
|
||||
trackColor: string;
|
||||
progressColor: string;
|
||||
}) {
|
||||
const center = size / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={trackColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{percentage !== null ? (
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={progressColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - (clampPercentage(percentage) / 100) * circumference}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
) : null}
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
export function ContextWindowMeter({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -103,15 +148,13 @@ export function ContextWindowMeter({
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
pending = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
const popoverWidth = Math.min(360, width - 24);
|
||||
const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24);
|
||||
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
@@ -121,79 +164,39 @@ export function ContextWindowMeter({
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
|
||||
// No usage yet: reserve the footprint with a track-only ring while a session is
|
||||
// active so the real ring fades in without shifting siblings. Render nothing when
|
||||
// no usage is expected.
|
||||
if (percentage === null || maxTokens === null || usedTokens === null) {
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={geometry.containerStyle}>
|
||||
<Svg
|
||||
width={geometry.svgSize}
|
||||
height={geometry.svgSize}
|
||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={geometry.center}
|
||||
cy={geometry.center}
|
||||
r={geometry.radius}
|
||||
fill="none"
|
||||
stroke={theme.colors.surface3}
|
||||
strokeWidth={geometry.strokeWidth}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
|
||||
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const context = useMemo(
|
||||
() =>
|
||||
percentage !== null && maxTokens !== null && usedTokens !== null
|
||||
? { percentage: Math.round(percentage), maxTokens, usedTokens }
|
||||
: null,
|
||||
[percentage, maxTokens, usedTokens],
|
||||
);
|
||||
const meterColors = useCallback(
|
||||
(theme: Theme) => ({
|
||||
progressColor: getProgressColor(percentage ?? 0, theme),
|
||||
trackColor: theme.colors.surface3,
|
||||
}),
|
||||
[percentage],
|
||||
);
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
const containerStyle = geometry.containerStyle;
|
||||
const ring = (
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
<ContextWindowRing
|
||||
size={geometry.svgSize}
|
||||
radius={geometry.radius}
|
||||
strokeWidth={geometry.strokeWidth}
|
||||
percentage={percentage}
|
||||
uniProps={meterColors}
|
||||
/>
|
||||
);
|
||||
const percentageLabel = showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage });
|
||||
const percentageLabel =
|
||||
showPercentage && context ? (
|
||||
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = context
|
||||
? t("contextWindow.accessibility", { percentage: context.percentage })
|
||||
: t("contextWindow.accessibilityNoData");
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
@@ -212,9 +215,7 @@ export function ContextWindowMeter({
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
context={context}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle={false}
|
||||
refreshable
|
||||
@@ -224,10 +225,51 @@ export function ContextWindowMeter({
|
||||
);
|
||||
}
|
||||
|
||||
const popoverStyle = showsUsage
|
||||
? [styles.usagePopover, { width: popoverWidth }]
|
||||
: styles.plainPopover;
|
||||
|
||||
// Native wide screens have no hover, so the details open in a tooltip on press. The tooltip
|
||||
// takes no presses, so its usage cards have no Refresh.
|
||||
if (isNative) {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
offset={8}
|
||||
maxWidth={showsUsage ? popoverWidth : undefined}
|
||||
style={popoverStyle}
|
||||
testID="context-window-meter-tooltip"
|
||||
>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
context={context}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle
|
||||
refreshable={false}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
<HoverCard>
|
||||
<HoverCardTrigger focusable accessibilityLabel={accessibilityLabel}>
|
||||
<View
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
@@ -235,28 +277,26 @@ export function ContextWindowMeter({
|
||||
>
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
</View>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="top"
|
||||
offset={8}
|
||||
maxWidth={showsUsage ? popoverWidth : undefined}
|
||||
style={showsUsage ? [styles.popover, { width: popoverWidth }] : undefined}
|
||||
testID="context-window-meter-tooltip"
|
||||
role="dialog"
|
||||
accessibilityLabel={t("contextWindow.title")}
|
||||
testID="context-window-details"
|
||||
style={popoverStyle}
|
||||
>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
context={context}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle
|
||||
refreshable={false}
|
||||
refreshable
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,11 +321,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
skeletonLabel: {
|
||||
width: 22,
|
||||
height: theme.fontSize.base,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
popover: { padding: theme.spacing[4], gap: theme.spacing[4] },
|
||||
// Plain details use a small inset; account usage cards have their own content density.
|
||||
plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] },
|
||||
usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] },
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,7 @@ 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";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -52,7 +53,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "@/usage";
|
||||
import {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
useHasUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -275,7 +281,7 @@ function FooterIconButton({
|
||||
testID,
|
||||
label,
|
||||
icon: Icon,
|
||||
iconSize,
|
||||
iconSizeAdjustment = 0,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
@@ -284,17 +290,20 @@ function FooterIconButton({
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSize?: number;
|
||||
iconSizeAdjustment?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
style={styles.footerIconButton(isCompact)}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
@@ -305,7 +314,7 @@ function FooterIconButton({
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
size={iconSize + iconSizeAdjustment}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -367,7 +376,7 @@ function SidebarHostPicker({
|
||||
label={label}
|
||||
icon={Server}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSize={theme.iconSize.md - 1}
|
||||
iconSizeAdjustment={-1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
@@ -414,46 +423,54 @@ function SidebarFooter({
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const openUsageScreen = useOpenUsageScreen();
|
||||
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
||||
return (
|
||||
<View style={styles.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}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<UsageSidebarRoot>
|
||||
<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}
|
||||
/>
|
||||
<SidebarUsageIcon label={labels.usage} theme={theme} />
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</UsageSidebarRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={openUsage}
|
||||
testID="sidebar-usage-icon"
|
||||
label={label}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -923,7 +940,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -940,14 +957,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
footerIconButton: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
}),
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
@@ -103,14 +106,14 @@ export function SidebarHelpMenu() {
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
style={styles.trigger(isCompact)}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<ThemedCircleHelp
|
||||
size={ICON_SIZE.md}
|
||||
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
@@ -181,14 +184,14 @@ export function SidebarHelpMenu() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
trigger: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
}),
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor";
|
||||
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
|
||||
|
||||
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
|
||||
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
|
||||
@@ -55,6 +55,18 @@ describe("computePosition", () => {
|
||||
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
|
||||
});
|
||||
|
||||
it("lets hover cards flip horizontally when the opposite side has room", () => {
|
||||
const result = position({
|
||||
triggerRect: { ...TRIGGER, x: 850 },
|
||||
placement: "right",
|
||||
flipHorizontal: true,
|
||||
});
|
||||
expect(result).toEqual({ x: 646, y: 100, actualPlacement: "left" });
|
||||
expect(
|
||||
position({ triggerRect: { ...TRIGGER, x: 850 }, placement: "right" }).actualPlacement,
|
||||
).toBe("right");
|
||||
});
|
||||
|
||||
it("keeps the surface inside a display area that does not start at the origin", () => {
|
||||
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
|
||||
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
|
||||
+19
-7
@@ -1,10 +1,8 @@
|
||||
import type { View } from "react-native";
|
||||
|
||||
/**
|
||||
* Where a floating menu surface goes relative to the thing that opened it.
|
||||
*
|
||||
* This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`.
|
||||
* Submenus need a third caller, so it lives here now and the menus import it.
|
||||
* Where a floating surface goes relative to the thing that opened it. Menus and hover cards
|
||||
* place themselves with it.
|
||||
*/
|
||||
|
||||
export type Placement = "top" | "bottom" | "left" | "right";
|
||||
@@ -38,14 +36,15 @@ export function measureElement(element: View): Promise<Rect> {
|
||||
* there is more room above. Both conditions matter: flipping into a side that is equally
|
||||
* cramped just moves the clipping, so a surface taller than the whole viewport stays put.
|
||||
*
|
||||
* Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the
|
||||
* caller choosing its side up front, because flipping it mid-open would move the surface out
|
||||
* from under the pointer that is travelling toward it.
|
||||
* Horizontal flipping is opt-in for hover cards. Submenus choose their direction up front so
|
||||
* they do not move out from under the pointer travelling toward them.
|
||||
*/
|
||||
function flipPlacement(input: {
|
||||
placement: Placement;
|
||||
triggerRect: Rect;
|
||||
contentHeight: number;
|
||||
contentWidth: number;
|
||||
flipHorizontal: boolean;
|
||||
displayArea: Rect;
|
||||
}): Placement {
|
||||
const { placement, triggerRect, contentHeight, displayArea } = input;
|
||||
@@ -58,6 +57,14 @@ function flipPlacement(input: {
|
||||
if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||
return "bottom";
|
||||
}
|
||||
if (input.flipHorizontal) {
|
||||
const spaceLeft = triggerRect.x - displayArea.x;
|
||||
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
|
||||
if (placement === "right" && spaceRight < input.contentWidth && spaceLeft > spaceRight)
|
||||
return "left";
|
||||
if (placement === "left" && spaceLeft < input.contentWidth && spaceRight > spaceLeft)
|
||||
return "right";
|
||||
}
|
||||
return placement;
|
||||
}
|
||||
|
||||
@@ -120,6 +127,7 @@ export function computePosition({
|
||||
placement,
|
||||
alignment,
|
||||
offset,
|
||||
flipHorizontal = false,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: Size;
|
||||
@@ -127,11 +135,15 @@ export function computePosition({
|
||||
placement: Placement;
|
||||
alignment: Alignment;
|
||||
offset: number;
|
||||
/** Hover cards can choose either side; submenu direction is owned by the menu. */
|
||||
flipHorizontal?: boolean;
|
||||
}): { x: number; y: number; actualPlacement: Placement } {
|
||||
const actualPlacement = flipPlacement({
|
||||
placement,
|
||||
triggerRect,
|
||||
contentHeight: contentSize.height,
|
||||
contentWidth: contentSize.width,
|
||||
flipHorizontal,
|
||||
displayArea,
|
||||
});
|
||||
const anchored = anchorToPlacement({
|
||||
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import {
|
||||
computePosition,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
type Placement,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "./anchor";
|
||||
|
||||
const CLOSE_GRACE_MS = 100;
|
||||
|
||||
interface HoverCardContextValue {
|
||||
open: boolean;
|
||||
layer: number;
|
||||
setTriggerRef: (node: View | null) => void;
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
openNow: () => void;
|
||||
scheduleClose: () => void;
|
||||
}
|
||||
|
||||
const HoverCardContext = createContext<HoverCardContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* A card that opens while the pointer is on its trigger and stays open while the pointer crosses
|
||||
* onto it, so its content can be pressed. Hover only exists on web; elsewhere the trigger renders
|
||||
* alone and the content never mounts. See docs/hover.md.
|
||||
*
|
||||
* ```tsx
|
||||
* <HoverCard>
|
||||
* <HoverCardTrigger>{trigger}</HoverCardTrigger>
|
||||
* <HoverCardContent placement="top">{details}</HoverCardContent>
|
||||
* </HoverCard>
|
||||
* ```
|
||||
*/
|
||||
export function HoverCard({
|
||||
disabled = false,
|
||||
children,
|
||||
}: {
|
||||
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
if (!isWeb) return children;
|
||||
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
|
||||
}
|
||||
|
||||
function WebHoverCard({
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const layer = useOverlayLayer("floating");
|
||||
const triggerRef = useRef<View>(null);
|
||||
const contentRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
clearTimeout(graceTimerRef.current);
|
||||
graceTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusInside = useCallback(() => {
|
||||
if (!isWeb) return false;
|
||||
const active = document.activeElement;
|
||||
return [triggerRef.current, contentRef.current].some((view) =>
|
||||
(view as unknown as HTMLElement | null)?.contains(active),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
if (focusInside()) return;
|
||||
if (graceTimerRef.current) return;
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
graceTimerRef.current = null;
|
||||
setOpen(false);
|
||||
}, CLOSE_GRACE_MS);
|
||||
}, [focusInside]);
|
||||
|
||||
const openNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, disabled]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
if (!isWeb) return false;
|
||||
const trigger = triggerRef.current as unknown as HTMLElement | null;
|
||||
if (!trigger) return false;
|
||||
if (event.key === "Escape" && open) {
|
||||
event.preventDefault();
|
||||
if (focusInside()) trigger.focus();
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
return true;
|
||||
} else if (
|
||||
focusInside() &&
|
||||
event.target === trigger &&
|
||||
["ArrowDown", "Enter", " "].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
openNow();
|
||||
requestAnimationFrame(() => {
|
||||
(contentRef.current as unknown as HTMLElement | null)
|
||||
?.querySelector<HTMLElement>(
|
||||
'button, [role="button"][tabindex="0"], a[href], input, [tabindex="0"]',
|
||||
)
|
||||
?.focus();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[clearGraceTimer, focusInside, open, openNow],
|
||||
);
|
||||
|
||||
const setOverlayScope = useWebOverlayRegistration({
|
||||
active: !disabled && open,
|
||||
layer,
|
||||
onKeyDown: keyPressed,
|
||||
manageFocus: false,
|
||||
});
|
||||
const setTriggerRef = useCallback(
|
||||
(node: View | null) => {
|
||||
triggerRef.current = node;
|
||||
setOverlayScope(node);
|
||||
},
|
||||
[setOverlayScope],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb) return;
|
||||
const trigger = triggerRef.current as unknown as HTMLElement | null;
|
||||
if (!trigger) return;
|
||||
const close = () => {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
};
|
||||
const focusEntered = (event: FocusEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (trigger.contains(target)) {
|
||||
openNow();
|
||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||
clearGraceTimer();
|
||||
}
|
||||
};
|
||||
const focusLeft = (event: FocusEvent) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (
|
||||
!trigger.contains(next) &&
|
||||
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
|
||||
) {
|
||||
scheduleClose();
|
||||
}
|
||||
};
|
||||
const activateClosedTrigger = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!open &&
|
||||
!event.defaultPrevented &&
|
||||
!event.isComposing &&
|
||||
["ArrowDown", "Enter", " "].includes(event.key)
|
||||
)
|
||||
keyPressed(event);
|
||||
};
|
||||
const scrolled = (event: Event) => {
|
||||
if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node))
|
||||
close();
|
||||
};
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
trigger.addEventListener("keydown", activateClosedTrigger);
|
||||
document.addEventListener("scroll", scrolled, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", focusEntered);
|
||||
document.removeEventListener("focusout", focusLeft);
|
||||
trigger.removeEventListener("keydown", activateClosedTrigger);
|
||||
document.removeEventListener("scroll", scrolled, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
|
||||
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
// it (including the bridge) cancels the pending close.
|
||||
useHoverSafeZone({
|
||||
enabled: open,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
onEnterSafeZone: clearGraceTimer,
|
||||
onLeaveSafeZone: scheduleClose,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [clearGraceTimer, disabled]);
|
||||
|
||||
useEffect(() => clearGraceTimer, [clearGraceTimer]);
|
||||
|
||||
const value = useMemo<HoverCardContextValue>(
|
||||
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
|
||||
[open, layer, setTriggerRef, openNow, scheduleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayLayerProvider layer={layer}>
|
||||
<HoverCardContext.Provider value={value}>{children}</HoverCardContext.Provider>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoverCardTrigger({
|
||||
children,
|
||||
focusable = false,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
focusable?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
}): ReactNode {
|
||||
const ctx = useContext(HoverCardContext);
|
||||
if (!ctx) return children;
|
||||
return (
|
||||
<View
|
||||
ref={ctx.setTriggerRef}
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
accessibilityRole={focusable ? "button" : undefined}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
collapsable={false}
|
||||
onPointerEnter={ctx.openNow}
|
||||
onPointerLeave={ctx.scheduleClose}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface HoverCardContentProps {
|
||||
placement: Placement;
|
||||
alignment?: Alignment;
|
||||
offset?: number;
|
||||
/** Layered over the default popover surface. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
role?: Role;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
/** Mounted only while the card is open, so whatever it fetches runs only then. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
|
||||
const ctx = useContext(HoverCardContext);
|
||||
if (!ctx?.open) return null;
|
||||
return (
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardSurface({
|
||||
layer,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
placement,
|
||||
alignment = "center",
|
||||
offset = 4,
|
||||
style,
|
||||
role,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
children,
|
||||
}: HoverCardContentProps & {
|
||||
layer: number;
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
let cancelled = false;
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect(rect);
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
placement,
|
||||
flipHorizontal: true,
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x, y });
|
||||
}, [alignment, contentSize, offset, placement, triggerRect]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
}),
|
||||
[position?.x, position?.y],
|
||||
);
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
surface: {
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
...theme.shadow.md,
|
||||
zIndex: 1000,
|
||||
},
|
||||
}));
|
||||
@@ -35,4 +35,4 @@ export {
|
||||
type MenuPresentation,
|
||||
} from "./menu-context";
|
||||
export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation";
|
||||
export type { Alignment, Placement, Rect, Size } from "./menu-anchor";
|
||||
export type { Alignment, Placement, Rect, Size } from "../anchor";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
openSubPage,
|
||||
type MenuPath,
|
||||
} from "./menu-navigation";
|
||||
import type { Rect } from "./menu-anchor";
|
||||
import type { Rect } from "../anchor";
|
||||
|
||||
/**
|
||||
* How a menu draws itself once it is open.
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type Placement,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "./menu-anchor";
|
||||
} from "../anchor";
|
||||
|
||||
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
|
||||
const CONTENT_ENTERING_DURATION_MS = 150;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { MenuPage } from "./menu-item";
|
||||
import { currentPageId, isSubPageOpen } from "./menu-navigation";
|
||||
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
|
||||
import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
|
||||
import type { Alignment, Placement } from "./menu-anchor";
|
||||
import type { Alignment, Placement } from "../anchor";
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dimensions, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Check,
|
||||
@@ -27,17 +23,13 @@ import type { Theme } from "@/styles/theme";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { Pressable } from "react-native";
|
||||
import type { GestureResponderEvent } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { copyToClipboard } from "@/utils/copy-to-clipboard";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
COUNTED_CHECK_PRESENTATIONS,
|
||||
@@ -48,52 +40,6 @@ import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-cop
|
||||
import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view";
|
||||
import { buildForgeChecksUrl } from "@/git/forge-url";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function computeHoverCardPosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: { width: number; height: number };
|
||||
displayArea: Rect;
|
||||
offset: number;
|
||||
}): { x: number; y: number } {
|
||||
let x = triggerRect.x + triggerRect.width + offset;
|
||||
let y = triggerRect.y;
|
||||
|
||||
// If it overflows right, try left
|
||||
if (x + contentSize.width > displayArea.width - 8) {
|
||||
x = triggerRect.x - contentSize.width - offset;
|
||||
}
|
||||
|
||||
// Constrain to screen
|
||||
const padding = 8;
|
||||
x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x));
|
||||
y = Math.max(
|
||||
displayArea.y + padding,
|
||||
Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y),
|
||||
);
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const HOVER_GRACE_MS = 100;
|
||||
const HOVER_CARD_WIDTH = 260;
|
||||
|
||||
interface WorkspaceHoverCardProps {
|
||||
@@ -110,237 +56,80 @@ export function WorkspaceHoverCard({
|
||||
disabled = false,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
if (!isWeb || isCompact) {
|
||||
if (isCompact) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCardDesktop
|
||||
workspace={workspace}
|
||||
prHint={prHint}
|
||||
isDragging={isDragging}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceHoverCardDesktop>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardDesktop({
|
||||
workspace,
|
||||
prHint,
|
||||
isDragging,
|
||||
disabled = false,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const contentRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerHoveredRef = useRef(false);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
clearTimeout(graceTimerRef.current);
|
||||
graceTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
if (graceTimerRef.current) return;
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
graceTimerRef.current = null;
|
||||
setOpen(false);
|
||||
}, HOVER_GRACE_MS);
|
||||
}, []);
|
||||
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
triggerHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
if (!isDragging && !disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, disabled, isDragging]);
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
triggerHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
// it (including the bridge) cancels the pending close.
|
||||
useHoverSafeZone({
|
||||
enabled: open,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
onEnterSafeZone: clearGraceTimer,
|
||||
onLeaveSafeZone: scheduleClose,
|
||||
});
|
||||
|
||||
// Close while another row interaction owns attention.
|
||||
useEffect(() => {
|
||||
if (isDragging || disabled) {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [clearGraceTimer, disabled, isDragging]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearGraceTimer();
|
||||
};
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={triggerRef}
|
||||
collapsable={false}
|
||||
onPointerEnter={handleTriggerEnter}
|
||||
onPointerLeave={handleTriggerLeave}
|
||||
>
|
||||
{children}
|
||||
{open ? (
|
||||
<WorkspaceHoverCardContent
|
||||
workspace={workspace}
|
||||
prHint={prHint}
|
||||
triggerRef={triggerRef}
|
||||
contentRef={contentRef}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<HoverCard disabled={isDragging || disabled}>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="right"
|
||||
role="menu"
|
||||
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
|
||||
testID="workspace-hover-card"
|
||||
style={styles.card}
|
||||
>
|
||||
<WorkspaceHoverCardContent workspace={workspace} prHint={prHint} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardContent({
|
||||
workspace,
|
||||
prHint,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
prHint: PrHint | null;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
contentRef: React.RefObject<View | null>;
|
||||
}): ReactElement | null {
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Measure trigger — same pattern as tooltip.tsx
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect(rect);
|
||||
return;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [triggerRef]);
|
||||
|
||||
// Compute position when both measurements are available
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
|
||||
const result = computeHoverCardPosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
offset: 4,
|
||||
});
|
||||
setPosition(result);
|
||||
}, [triggerRect, contentSize]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
}),
|
||||
[position?.x, position?.y],
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
|
||||
testID="workspace-hover-card"
|
||||
style={styles.card}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.cardInfoRow}>
|
||||
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<HostRow serverId={workspace.serverId} />
|
||||
{workspace.currentBranch ? (
|
||||
<CopyableInfoRow
|
||||
icon={ThemedGitBranch}
|
||||
value={workspace.currentBranch}
|
||||
copyValue={workspace.currentBranch}
|
||||
copyLabel={t("workspace.hoverCard.copyBranchName")}
|
||||
testID="hover-card-workspace-branch"
|
||||
/>
|
||||
) : null}
|
||||
{workspace.workspaceDirectoryLabel ? (
|
||||
<CopyableInfoRow
|
||||
icon={ThemedFolder}
|
||||
value={workspace.workspaceDirectoryLabel}
|
||||
copyValue={workspace.workspaceDirectory}
|
||||
copyLabel={t("workspace.hoverCard.copyPath")}
|
||||
testID="hover-card-workspace-cwd"
|
||||
/>
|
||||
) : null}
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<ChecksSummaryPressable
|
||||
checks={prHint.checks}
|
||||
url={prHint.url}
|
||||
forge={prHint.forge}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</FloatingSurface>
|
||||
<>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
</Portal>
|
||||
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.cardInfoRow}>
|
||||
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<HostRow serverId={workspace.serverId} />
|
||||
{workspace.currentBranch ? (
|
||||
<CopyableInfoRow
|
||||
icon={ThemedGitBranch}
|
||||
value={workspace.currentBranch}
|
||||
copyValue={workspace.currentBranch}
|
||||
copyLabel={t("workspace.hoverCard.copyBranchName")}
|
||||
testID="hover-card-workspace-branch"
|
||||
/>
|
||||
) : null}
|
||||
{workspace.workspaceDirectoryLabel ? (
|
||||
<CopyableInfoRow
|
||||
icon={ThemedFolder}
|
||||
value={workspace.workspaceDirectoryLabel}
|
||||
copyValue={workspace.workspaceDirectory}
|
||||
copyLabel={t("workspace.hoverCard.copyPath")}
|
||||
testID="hover-card-workspace-cwd"
|
||||
/>
|
||||
) : null}
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} forge={prHint.forge} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -561,14 +350,6 @@ function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; ho
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
@@ -581,7 +362,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
zIndex: 1000,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -15,7 +15,11 @@ import Animated, {
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
@@ -34,11 +38,12 @@ function ComposerViewport({
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
|
||||
function useCenteredClearance(safeAreaBottom: number) {
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [formBottom, setFormBottom] = useState(0);
|
||||
const measureViewport = useCallback((event: LayoutChangeEvent) => {
|
||||
setViewportHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const measureForm = useCallback((event: LayoutChangeEvent) => {
|
||||
const { y, height } = event.nativeEvent.layout;
|
||||
setFormBottom(y + height);
|
||||
}, []);
|
||||
return {
|
||||
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
|
||||
measureViewport,
|
||||
measureForm,
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
@@ -91,6 +114,7 @@ export function ComposerDock({
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
if (centered) {
|
||||
@@ -99,8 +123,13 @@ export function ComposerDock({
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
|
||||
const height = resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
centered: false,
|
||||
});
|
||||
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
|
||||
expect(582 - 24 - 308 - height).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps a centered tablet form below the header after translation", () => {
|
||||
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
|
||||
const viewport = 1000;
|
||||
const bottomInset = 80;
|
||||
const keyboardShift = 300;
|
||||
const height = resolveComposerCapacity({
|
||||
height: 1000,
|
||||
bottomInset: 80,
|
||||
keyboardShift: 300,
|
||||
height: viewport,
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(310);
|
||||
expect((1000 - 80 - height) / 2 - 300).toBe(5);
|
||||
expect(height).toBe(695);
|
||||
const restingTop = (viewport - bottomInset - height) / 2;
|
||||
const bottomClearance = resolveCenteredClearance({
|
||||
viewportHeight: viewport,
|
||||
safeAreaBottom: 0,
|
||||
formBottom: restingTop + height,
|
||||
});
|
||||
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
|
||||
});
|
||||
|
||||
it("leaves a centered form room to edit on a short foldable viewport", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({
|
||||
height: 725,
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
});
|
||||
|
||||
it("rests a centered form on the keyboard above the bottom safe area", () => {
|
||||
// The keyboard shift excludes the safe area that the keyboard also covers.
|
||||
const geometry = {
|
||||
height: 660,
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
|
||||
const formBottom = restingTop + height;
|
||||
const clearance = resolveCenteredClearance({
|
||||
viewportHeight: geometry.height,
|
||||
safeAreaBottom: geometry.safeAreaBottom,
|
||||
formBottom,
|
||||
});
|
||||
const translation = Math.max(0, geometry.keyboardShift - clearance);
|
||||
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
|
||||
expect(formBottom - translation).toBe(keyboardTop);
|
||||
expect(restingTop - translation).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the measured viewport before the first keyboard opening", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 300,
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
interface ComposerGeometry {
|
||||
height: number;
|
||||
bottomInset: number;
|
||||
/** The keyboard also covers this space below its shift. */
|
||||
safeAreaBottom: number;
|
||||
keyboardShift: number;
|
||||
centered: boolean;
|
||||
}
|
||||
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
|
||||
|
||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
"worklet";
|
||||
// A centered form grows upward by half its height. Reserve both halves so
|
||||
// translating it still leaves five layout points below the header.
|
||||
const clearance = input.keyboardShift + 5;
|
||||
const reservedSpace = input.centered ? clearance * 2 : clearance;
|
||||
return Math.max(0, input.height - input.bottomInset - reservedSpace);
|
||||
// A centered form only translates by the keyboard overlap, so once the keyboard
|
||||
// reaches it the form sits on the keyboard and fits above it, not twice over.
|
||||
// A bottom-anchored composer pads the safe area inside its own capacity.
|
||||
const available = input.centered
|
||||
? Math.min(
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height - input.bottomInset - input.keyboardShift;
|
||||
return Math.max(0, available - 5);
|
||||
}
|
||||
|
||||
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||
export function resolveCenteredClearance(input: {
|
||||
viewportHeight: number;
|
||||
safeAreaBottom: number;
|
||||
formBottom: number;
|
||||
}): number {
|
||||
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
|
||||
}
|
||||
|
||||
export function updateComposerCapacity(
|
||||
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
useImperativeHandle,
|
||||
memo,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -278,41 +278,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderContextWindowMeter(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
contextWindowMaxTokens: number | null,
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
showPercentage: boolean,
|
||||
pending: boolean,
|
||||
glyphSize: number,
|
||||
): ReactElement | null {
|
||||
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
|
||||
if (!hasData && !pending) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ContextWindowMeter
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
showPercentage={showPercentage}
|
||||
pending={pending}
|
||||
glyphSize={glyphSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveContextWindowPlacement(
|
||||
meter: ReactElement | null,
|
||||
reserveSlot: boolean,
|
||||
): ReactNode {
|
||||
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
|
||||
}
|
||||
|
||||
interface RenderLeftContentArgs {
|
||||
agentControls: DraftAgentControlsProps | undefined;
|
||||
agentId: string;
|
||||
@@ -1006,16 +971,6 @@ interface ComposerProps {
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const StableMessageInput = memo(MessageInput);
|
||||
|
||||
function resolveContextWindowValues(
|
||||
rawMax: number | null,
|
||||
rawUsed: number | null,
|
||||
): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } {
|
||||
if (typeof rawMax === "number" && typeof rawUsed === "number") {
|
||||
return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed };
|
||||
}
|
||||
return { contextWindowMaxTokens: null, contextWindowUsedTokens: null };
|
||||
}
|
||||
|
||||
interface ComposerAutocompleteHandle {
|
||||
onKeyPress: (event: ComposerKeyPressEvent) => boolean;
|
||||
}
|
||||
@@ -1312,6 +1267,7 @@ function ComposerContentImpl({
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const hasFinePointer = useHasFinePointer();
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
|
||||
const hasText = useSyncExternalStore(
|
||||
@@ -2069,40 +2025,31 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
|
||||
agentState.contextWindowMaxTokens,
|
||||
agentState.contextWindowUsedTokens,
|
||||
);
|
||||
|
||||
const contextWindowPending = agentState.status === "initializing" || isAgentRunning;
|
||||
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
|
||||
|
||||
const contextWindowMeter = useMemo(
|
||||
const beforeVoiceContent = useMemo(
|
||||
() =>
|
||||
renderContextWindowMeter(
|
||||
serverId,
|
||||
agentId,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
false,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
hasAgent ? (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
<ContextWindowMeter
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
maxTokens={agentState.contextWindowMaxTokens}
|
||||
usedTokens={agentState.contextWindowUsedTokens}
|
||||
totalCostUsd={agentState.totalCostUsd}
|
||||
glyphSize={contextWindowMeterGlyphSize}
|
||||
/>
|
||||
</View>
|
||||
) : null,
|
||||
[
|
||||
hasAgent,
|
||||
serverId,
|
||||
agentId,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.contextWindowMaxTokens,
|
||||
agentState.contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
],
|
||||
);
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}</>,
|
||||
[contextWindowMeter, hasAgent],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
() =>
|
||||
@@ -2383,7 +2330,8 @@ function ComposerContentImpl({
|
||||
{ disabled: isSubmitLoadingVisible },
|
||||
);
|
||||
|
||||
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
|
||||
// Focusing the composer on a touch screen raises the on-screen keyboard over the conversation.
|
||||
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer;
|
||||
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
|
||||
const sendErrorNode = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -94,14 +94,30 @@ describe("hover safe-zone tracker", () => {
|
||||
expect(handle.leaves).toBe(1);
|
||||
});
|
||||
|
||||
it("bridges the gap to content above or below the trigger", () => {
|
||||
const handle = createHandle({
|
||||
trigger: { left: 100, right: 130, top: 60, bottom: 90 },
|
||||
content: { left: 0, right: 300, top: 0, bottom: 52 },
|
||||
});
|
||||
|
||||
// In the gap between the card and the trigger below it.
|
||||
handle.pointerMoved(115, 56);
|
||||
expect(handle.enters).toBe(1);
|
||||
expect(handle.leaves).toBe(0);
|
||||
|
||||
// Beside the trigger, past the gap — outside.
|
||||
handle.pointerMoved(200, 75);
|
||||
expect(handle.leaves).toBe(1);
|
||||
});
|
||||
|
||||
it("treats overlapping trigger and content as having no bridge", () => {
|
||||
const handle = createHandle({
|
||||
trigger: { left: 0, right: 200, top: 0, bottom: 50 },
|
||||
content: { left: 100, right: 300, top: 60, bottom: 100 },
|
||||
content: { left: 100, right: 300, top: 40, bottom: 100 },
|
||||
});
|
||||
|
||||
// Outside both rects, in what would be a bridge — should be outside.
|
||||
handle.pointerMoved(150, 55);
|
||||
// Outside both rects, in the corner neither of them covers — should be outside.
|
||||
handle.pointerMoved(250, 20);
|
||||
expect(handle.enters).toBe(0);
|
||||
expect(handle.leaves).toBe(1);
|
||||
});
|
||||
|
||||
@@ -62,9 +62,20 @@ function isInsideSafeZone(
|
||||
if (isInsideRect(trigger, x, y)) return true;
|
||||
if (isInsideRect(content, x, y)) return true;
|
||||
if (!trigger || !content) return false;
|
||||
return (
|
||||
isInsideHorizontalBridge(trigger, content, x, y) ||
|
||||
isInsideVerticalBridge(trigger, content, x, y)
|
||||
);
|
||||
}
|
||||
|
||||
// Bridge: the horizontal strip connecting trigger and content, stretched
|
||||
// vertically to span both. If they overlap horizontally there's no bridge.
|
||||
// A card beside its trigger: the strip between them, stretched vertically to
|
||||
// span both. If they overlap horizontally there's no horizontal bridge.
|
||||
function isInsideHorizontalBridge(
|
||||
trigger: RectLike,
|
||||
content: RectLike,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
const bridgeLeft = Math.min(trigger.right, content.right);
|
||||
const bridgeRight = Math.max(trigger.left, content.left);
|
||||
if (bridgeLeft >= bridgeRight) return false;
|
||||
@@ -72,3 +83,19 @@ function isInsideSafeZone(
|
||||
const bridgeBottom = Math.max(trigger.bottom, content.bottom);
|
||||
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
|
||||
}
|
||||
|
||||
// A card above or below its trigger: the strip between them, stretched
|
||||
// horizontally to span both. If they overlap vertically there's no vertical bridge.
|
||||
function isInsideVerticalBridge(
|
||||
trigger: RectLike,
|
||||
content: RectLike,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
const bridgeTop = Math.min(trigger.bottom, content.bottom);
|
||||
const bridgeBottom = Math.max(trigger.top, content.top);
|
||||
if (bridgeTop >= bridgeBottom) return false;
|
||||
const bridgeLeft = Math.min(trigger.left, content.left);
|
||||
const bridgeRight = Math.max(trigger.right, content.right);
|
||||
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
contentMaxWidth: null,
|
||||
syntaxTheme: "one",
|
||||
workspaceTitleSource: "title",
|
||||
sidebarWorkspaceTrailing: "diff",
|
||||
sidebarWorkspaceTrailing: "timestamp",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarNavItems: [],
|
||||
@@ -242,7 +242,7 @@ const StoredAppSettingsSchema = z
|
||||
.catch(null),
|
||||
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
|
||||
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
|
||||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
|
||||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("timestamp"),
|
||||
sidebarRowItems: SidebarRowItemsSchema,
|
||||
sidebarChecksDisplay: z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
|
||||
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
|
||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||
fields: {
|
||||
target: "مضيف SSH",
|
||||
password: "كلمة مرور الدايمون",
|
||||
optional: "خياري",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
},
|
||||
actions: {
|
||||
cancel: "إلغاء",
|
||||
@@ -1931,6 +1937,8 @@ export const ar: TranslationResources = {
|
||||
dismiss: "رفض",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "لا توجد بيانات للسياق",
|
||||
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
|
||||
title: "نافذة السياق",
|
||||
used: "تم استخدام{{percentage}}%",
|
||||
tokens: "رموز{{used}}/{{max}}",
|
||||
|
||||
@@ -1727,6 +1727,12 @@ export const en = {
|
||||
helper: "Connect to a Paseo daemon running on the remote host.",
|
||||
fields: {
|
||||
target: "SSH host",
|
||||
password: "Daemon password",
|
||||
optional: "Optional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Show password",
|
||||
hide: "Hide password",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancel",
|
||||
@@ -1956,6 +1962,8 @@ export const en = {
|
||||
dismiss: "Dismiss",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "No context data",
|
||||
accessibilityNoData: "Context window: No context data",
|
||||
title: "Context window",
|
||||
used: "{{percentage}}% used",
|
||||
tokens: "{{used}} / {{max}} tokens",
|
||||
|
||||
@@ -1749,6 +1749,12 @@ export const es: TranslationResources = {
|
||||
helper: "Conéctate a un daemon de Paseo en el host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
password: "Contraseña del daemon",
|
||||
optional: "Opcional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar contraseña",
|
||||
hide: "Ocultar contraseña",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
@@ -1979,6 +1985,8 @@ export const es: TranslationResources = {
|
||||
dismiss: "Despedir",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "No hay datos de contexto",
|
||||
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
|
||||
title: "ventana contextual",
|
||||
used: "{{percentage}}% utilizado",
|
||||
tokens: "Fichas{{used}}/{{max}}",
|
||||
|
||||
@@ -1753,6 +1753,12 @@ export const fr: TranslationResources = {
|
||||
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
|
||||
fields: {
|
||||
target: "Hôte SSH",
|
||||
password: "Mot de passe du démon",
|
||||
optional: "Facultatif",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Afficher le mot de passe",
|
||||
hide: "Masquer le mot de passe",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Annuler",
|
||||
@@ -1984,6 +1990,8 @@ export const fr: TranslationResources = {
|
||||
dismiss: "Rejeter",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Aucune donnée de contexte",
|
||||
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
|
||||
title: "Fenêtre contextuelle",
|
||||
used: "{{percentage}}% utilisé",
|
||||
tokens: "Jetons{{used}}/{{max}}",
|
||||
|
||||
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
|
||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||
fields: {
|
||||
target: "SSH ホスト",
|
||||
password: "デーモンのパスワード",
|
||||
optional: "任意",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
},
|
||||
actions: {
|
||||
cancel: "キャンセル",
|
||||
@@ -1951,6 +1957,8 @@ export const ja: TranslationResources = {
|
||||
dismiss: "閉じる",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "コンテキストデータがありません",
|
||||
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
|
||||
title: "コンテキストウィンドウ",
|
||||
used: "{{percentage}}%使用",
|
||||
tokens: "{{used}} / {{max}}トークン",
|
||||
|
||||
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
|
||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||
fields: {
|
||||
target: "SSH 호스트",
|
||||
password: "데몬 비밀번호",
|
||||
optional: "선택 사항",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
},
|
||||
actions: {
|
||||
cancel: "취소",
|
||||
@@ -1941,6 +1947,8 @@ export const ko: TranslationResources = {
|
||||
dismiss: "닫기",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "컨텍스트 데이터 없음",
|
||||
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
|
||||
title: "컨텍스트 윈도우",
|
||||
used: "{{percentage}}% 사용됨",
|
||||
tokens: "{{used}} / {{max}} 토큰",
|
||||
|
||||
@@ -1734,6 +1734,12 @@ export const ptBR: TranslationResources = {
|
||||
helper: "Conecte-se a um daemon Paseo no host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
password: "Senha do daemon",
|
||||
optional: "Opcional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar senha",
|
||||
hide: "Ocultar senha",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
@@ -1964,6 +1970,8 @@ export const ptBR: TranslationResources = {
|
||||
dismiss: "Dispensar",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Sem dados de contexto",
|
||||
accessibilityNoData: "Janela de contexto: sem dados de contexto",
|
||||
title: "Janela de contexto",
|
||||
used: "{{percentage}}% usado",
|
||||
tokens: "{{used}} / {{max}} tokens",
|
||||
|
||||
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
|
||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||
fields: {
|
||||
target: "Хост SSH",
|
||||
password: "Пароль демона",
|
||||
optional: "Необязательно",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Отмена",
|
||||
@@ -1964,6 +1970,8 @@ export const ru: TranslationResources = {
|
||||
dismiss: "Закрыть",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Нет данных о контексте",
|
||||
accessibilityNoData: "Контекстное окно: нет данных о контексте",
|
||||
title: "Контекстное окно",
|
||||
used: "Использовано: {{percentage}}%",
|
||||
tokens: "Токены: {{used}} / {{max}}",
|
||||
|
||||
@@ -1683,6 +1683,12 @@ export const zhCN: TranslationResources = {
|
||||
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
||||
fields: {
|
||||
target: "SSH 主机",
|
||||
password: "守护进程密码",
|
||||
optional: "可选",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "显示密码",
|
||||
hide: "隐藏密码",
|
||||
},
|
||||
actions: {
|
||||
cancel: "取消",
|
||||
@@ -1909,6 +1915,8 @@ export const zhCN: TranslationResources = {
|
||||
dismiss: "关闭",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "暂无上下文数据",
|
||||
accessibilityNoData: "上下文窗口:暂无上下文数据",
|
||||
title: "上下文窗口",
|
||||
used: "已使用 {{percentage}}%",
|
||||
tokens: "{{used}} / {{max}} tokens",
|
||||
|
||||
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: enabled ? -shift.value : 0 }],
|
||||
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
[height, insets.bottom, progress],
|
||||
() =>
|
||||
Animated.add(
|
||||
Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
bottomClearance,
|
||||
).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
[bottomClearance, height, insets.bottom, progress],
|
||||
);
|
||||
const keyboardStyle = useMemo(
|
||||
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
||||
|
||||
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
bottomClearance: _bottomClearance,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
|
||||
@@ -88,6 +88,7 @@ interface WebOverlayEntry {
|
||||
getScope: () => HTMLElement | null;
|
||||
getKeyHandler: () => WebOverlayKeyHandler;
|
||||
restoreFocus: HTMLElement | null;
|
||||
manageFocus: boolean;
|
||||
}
|
||||
|
||||
const webOverlayEntries: WebOverlayEntry[] = [];
|
||||
@@ -140,7 +141,7 @@ function focusFirstElement(scope: HTMLElement): void {
|
||||
function handleWebOverlayFocus(event: FocusEvent): void {
|
||||
const top = getTopWebOverlay();
|
||||
const scope = top?.getScope();
|
||||
if (!scope || scope.contains(event.target as Node)) return;
|
||||
if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return;
|
||||
if (webOverlayFocusCheckQueued) return;
|
||||
|
||||
// React can autofocus a child before its parent scope ref attaches. Defer
|
||||
@@ -149,8 +150,10 @@ function handleWebOverlayFocus(event: FocusEvent): void {
|
||||
webOverlayFocusCheckQueued = true;
|
||||
queueMicrotask(() => {
|
||||
webOverlayFocusCheckQueued = false;
|
||||
const currentScope = getTopWebOverlay()?.getScope();
|
||||
if (!currentScope || currentScope.contains(document.activeElement)) return;
|
||||
const currentTop = getTopWebOverlay();
|
||||
const currentScope = currentTop?.getScope();
|
||||
if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement))
|
||||
return;
|
||||
focusFirstElement(currentScope);
|
||||
});
|
||||
}
|
||||
@@ -169,7 +172,7 @@ export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean {
|
||||
const scope = top?.getScope();
|
||||
if (!top || !scope) return false;
|
||||
|
||||
if (event.key === "Tab") {
|
||||
if (top.manageFocus && event.key === "Tab") {
|
||||
const focusable = getFocusableElements(scope);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
@@ -214,7 +217,12 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
|
||||
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
const scope = entry.getScope();
|
||||
if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) {
|
||||
if (
|
||||
entry.manageFocus &&
|
||||
getTopWebOverlay() === entry &&
|
||||
scope &&
|
||||
!scope.contains(document.activeElement)
|
||||
) {
|
||||
focusFirstElement(scope);
|
||||
}
|
||||
});
|
||||
@@ -225,6 +233,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
|
||||
if (index !== -1) webOverlayEntries.splice(index, 1);
|
||||
detachWebOverlayListeners();
|
||||
if (
|
||||
entry.manageFocus &&
|
||||
options?.restoreFocus !== false &&
|
||||
entry.restoreFocus &&
|
||||
document.contains(entry.restoreFocus)
|
||||
@@ -239,17 +248,20 @@ interface WebOverlayRegistration {
|
||||
layer: number;
|
||||
onKeyDown: WebOverlayKeyHandler;
|
||||
restoreFocusRef?: React.RefObject<unknown>;
|
||||
/** Hover surfaces claim overlay keys while preserving the user's existing focus. */
|
||||
manageFocus?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a focus scope in the same relative layer model used for painting.
|
||||
* Only the highest painted overlay receives keyboard input; focus is trapped
|
||||
* there and restored to the opener when that overlay closes.
|
||||
* Only the highest painted overlay receives keyboard input. By default, focus is trapped
|
||||
* there and restored to the opener when it closes; hover surfaces preserve focus instead.
|
||||
*/
|
||||
export function useWebOverlayRegistration({
|
||||
active,
|
||||
layer,
|
||||
onKeyDown,
|
||||
manageFocus = true,
|
||||
restoreFocusRef: preferredRestoreFocusRef,
|
||||
}: WebOverlayRegistration) {
|
||||
const idRef = useRef(Symbol("web-overlay"));
|
||||
@@ -305,10 +317,11 @@ export function useWebOverlayRegistration({
|
||||
getScope: () => scopeRef.current,
|
||||
getKeyHandler: () => keyHandlerRef.current,
|
||||
restoreFocus: capturedRestoreFocusRef.current,
|
||||
manageFocus,
|
||||
};
|
||||
removeEntryRef.current = addWebOverlay(entry);
|
||||
registeredRef.current = true;
|
||||
}, []);
|
||||
}, [manageFocus]);
|
||||
|
||||
const setScope = useCallback(
|
||||
(node: unknown) => {
|
||||
|
||||
@@ -42,7 +42,15 @@ export function MobilePanelOverlay({
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const isVisible = isLeft ? position.value < 0 : position.value > 0;
|
||||
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||
if (isWeb) {
|
||||
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||
}
|
||||
// Preserve native scroll ranges while hidden. Collapsing layout makes Android
|
||||
// spring retained scroll offsets back to zero on touch cancellation or release.
|
||||
return {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? ("box-none" as const) : ("none" as const),
|
||||
};
|
||||
}, [isLeft]);
|
||||
|
||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||
@@ -79,10 +87,7 @@ export function MobilePanelOverlay({
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={styles.overlay}
|
||||
>
|
||||
<Animated.View
|
||||
pointerEvents={overlayPointerEvents}
|
||||
style={[styles.overlay, overlayAnimatedStyle]}
|
||||
>
|
||||
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
|
||||
<Pressable
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
|
||||
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
|
||||
const store = createPairingStore({
|
||||
password: " s3cret ",
|
||||
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||
});
|
||||
answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
await store.probeAndUpsertRemoteSshConnection({
|
||||
host: "deploy@example.com",
|
||||
password: " s3cret ",
|
||||
});
|
||||
|
||||
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
|
||||
expect(host?.password).toBe(" s3cret ");
|
||||
expect(host?.connections[0]).toMatchObject({
|
||||
type: "remoteSsh",
|
||||
host: "deploy@example.com",
|
||||
});
|
||||
|
||||
// The fix promises the saved password is sent on every connection: a later
|
||||
// cycle must authenticate from the stored profile alone.
|
||||
await store.runProbeCycleNow("srv_ssh");
|
||||
await waitForHostOnline(store, "srv_ssh");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
|
||||
const store = createPairingStore({
|
||||
password: "s3cret",
|
||||
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||
});
|
||||
answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
const failure = await store
|
||||
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
|
||||
.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
|
||||
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
|
||||
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
return this.probeAndUpsertConnection({
|
||||
label: input.label,
|
||||
// The daemon hashes and compares the password verbatim (`paseo daemon
|
||||
// set-password` keeps what it reads), so whitespace is significant here.
|
||||
password: input.password ? input.password : undefined,
|
||||
connection: createRemoteSshHostConnection(input),
|
||||
});
|
||||
}
|
||||
@@ -2844,6 +2848,7 @@ export interface HostMutations {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { UsageRefreshButton } from "./refresh-button";
|
||||
import { UsageOptions } from "./options";
|
||||
import type { UsageHost } from "./model";
|
||||
import { useHostUsage } from "./queries";
|
||||
import type { UsageView } from "./types";
|
||||
@@ -16,7 +17,7 @@ export interface UsageHostSelection {
|
||||
|
||||
/**
|
||||
* The controls on the right of every usage title row: the host filter when there is more than one
|
||||
* host, and Refresh all. A host that cannot report usage keeps only the host filter.
|
||||
* host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter.
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
@@ -41,7 +42,10 @@ export function UsageControls({
|
||||
/>
|
||||
) : null}
|
||||
{view.kind === "unavailable" ? null : (
|
||||
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
|
||||
<>
|
||||
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
|
||||
<UsageOptions />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,4 +2,9 @@ export { AgentUsage } from "./agent-usage";
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { useHostReportsUsage } from "./queries";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item";
|
||||
export {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
useHasUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "./sidebar-item";
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCollapsibleRow,
|
||||
SettingsRow,
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SettingsRow, SettingsSwitch } from "@/components/settings";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
@@ -17,16 +18,33 @@ const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The usage options, folded above the reports: whether the sidebar shows the summary (the same
|
||||
* switch as Settings > Sidebar) and whether percents read as used or remaining.
|
||||
*/
|
||||
export function UsageOptions({ display }: { display: UsageDisplay }) {
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
|
||||
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
|
||||
export function UsageOptions() {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { display } = useUsagePreferences();
|
||||
const { inSidebar, setInSidebar } = useUsageInSidebar();
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<SettingsCard>
|
||||
<SettingsCollapsibleRow label={usageCopy.options} testID="usage-options">
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton
|
||||
kind="menu"
|
||||
label={usageCopy.options}
|
||||
compact={compact}
|
||||
testID="usage-options-menu"
|
||||
>
|
||||
<ThemedSettings
|
||||
size={paneContentToolbarIconSize(compact)}
|
||||
uniProps={extraMutedIconColorMapping}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={340}
|
||||
sheetTitle={usageCopy.options}
|
||||
testID="usage-options-surface"
|
||||
>
|
||||
<View testID="usage-options-fields">
|
||||
<SettingsSwitch
|
||||
label={usageCopy.showInSidebar}
|
||||
hint={usageCopy.showInSidebarHint}
|
||||
@@ -34,17 +52,19 @@ export function UsageOptions({ display }: { display: UsageDisplay }) {
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
<SettingsRow label={usageCopy.displayAs}>
|
||||
<SegmentedControl
|
||||
options={DISPLAY_AS_OPTIONS}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
size="sm"
|
||||
testID="usage-display-as"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsCollapsibleRow>
|
||||
</SettingsCard>
|
||||
</View>
|
||||
<View style={settingsStyles.rowBorder}>
|
||||
<SettingsRow label={usageCopy.displayAs}>
|
||||
<SegmentedControl
|
||||
options={DISPLAY_AS_OPTIONS}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
size="sm"
|
||||
testID="usage-display-as"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { router } from "expo-router";
|
||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Pressable,
|
||||
@@ -12,7 +20,6 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildUsageRoute } from "@/utils/host-routes";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { useUsagePreferences, type UsageDisplay } from "./display";
|
||||
@@ -29,7 +36,6 @@ import {
|
||||
type PinnedUsageSource,
|
||||
} from "./pinned";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageOptions } from "./options";
|
||||
import { UsageBody } from "./usage-section";
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
@@ -50,55 +56,51 @@ export function useHasUsageSummary(): boolean {
|
||||
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
|
||||
*/
|
||||
export function UsageSidebarItem() {
|
||||
const { display } = useUsagePreferences();
|
||||
const sources = useUsageSummary();
|
||||
if (sources.length === 0) return null;
|
||||
return <UsageEntry sources={sources} display={display} />;
|
||||
}
|
||||
|
||||
/** Opens the Usage screen, over the sidebar on compact layouts. */
|
||||
export function useOpenUsageScreen(): () => void {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
return useCallback(() => {
|
||||
if (isCompact) showMobileAgent();
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompact, showMobileAgent]);
|
||||
}
|
||||
|
||||
function UsageEntry({
|
||||
sources,
|
||||
display,
|
||||
}: {
|
||||
sources: readonly PinnedUsageSource[];
|
||||
display: UsageDisplay;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(builtinSidebarNavLabelKey("usage"));
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
const sources = useUsageSummary();
|
||||
if (sources.length === 0) return null;
|
||||
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
|
||||
}
|
||||
|
||||
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
|
||||
|
||||
/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */
|
||||
export function UsageSidebarRoot({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const { display } = useUsagePreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openUsageScreen = useOpenUsageScreen();
|
||||
const [open, setOpen] = useState(false);
|
||||
// The sheet mounts on first open; the summary already owns the report query.
|
||||
const [sheetMounted, setSheetMounted] = useState(false);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!isCompact) {
|
||||
openUsageScreen();
|
||||
const openUsage = useCallback(() => {
|
||||
if (isCompact) {
|
||||
setSheetMounted(true);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
setSheetMounted(true);
|
||||
setOpen(true);
|
||||
}, [isCompact, openUsageScreen]);
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompact]);
|
||||
|
||||
const trigger = <PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />;
|
||||
if (!isCompact) return trigger;
|
||||
return (
|
||||
<SidebarPopoverRoot open={open} onOpenChange={setOpen}>
|
||||
{trigger}
|
||||
{sheetMounted ? <UsageSheet title={label} display={display} /> : null}
|
||||
</SidebarPopoverRoot>
|
||||
<OpenSidebarUsageContext.Provider value={openUsage}>
|
||||
<SidebarPopoverRoot open={isCompact && open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
{isCompact && sheetMounted ? (
|
||||
<UsageSheet title={t(builtinSidebarNavLabelKey("usage"))} display={display} />
|
||||
) : null}
|
||||
</SidebarPopoverRoot>
|
||||
</OpenSidebarUsageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Both sidebar entry points send the same open command. */
|
||||
export function useOpenSidebarUsage(): () => void {
|
||||
const openUsage = useContext(OpenSidebarUsageContext);
|
||||
if (!openUsage) throw new Error("Sidebar Usage must be inside UsageSidebarRoot.");
|
||||
return openUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls
|
||||
* in its title row.
|
||||
@@ -144,7 +146,6 @@ function HostUsageSheet({
|
||||
testID="sidebar-usage-sheet"
|
||||
>
|
||||
<View style={styles.sheetBody} testID="usage-expanded">
|
||||
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
|
||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||
</View>
|
||||
</SidebarPopoverSurface>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageOptions } from "./options";
|
||||
import { UsageBody, UsageMessage } from "./usage-section";
|
||||
|
||||
// The screen is reachable by URL, so there may be no history to go back to.
|
||||
@@ -66,7 +65,6 @@ function HostUsage({
|
||||
return (
|
||||
<UsagePage actions={controls}>
|
||||
<View testID={`usage-host-${serverId}`}>
|
||||
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
|
||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||
</View>
|
||||
</UsagePage>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/server": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/server": "0.11.0-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
|
||||
.argument("<id>", "Agent ID (or prefix)");
|
||||
}
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
@@ -157,31 +158,10 @@ export async function runAttachCommand(
|
||||
await unsubscribe.ready;
|
||||
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
|
||||
|
||||
// Handle Ctrl+C to detach gracefully
|
||||
let detached = false;
|
||||
const detach = () => {
|
||||
if (detached) return;
|
||||
detached = true;
|
||||
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
client
|
||||
.close()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", detach);
|
||||
process.on("SIGTERM", detach);
|
||||
|
||||
// Keep the process alive
|
||||
await new Promise(() => {
|
||||
// Wait indefinitely until interrupted
|
||||
});
|
||||
await waitForStop();
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
@@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js";
|
||||
const agent = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "running",
|
||||
archivedAt: null,
|
||||
archivedAt: null as string | null,
|
||||
cwd: "/tmp/project",
|
||||
};
|
||||
const archivedAgent = {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "closed",
|
||||
archivedAt: "2026-10-01T00:00:00.000Z",
|
||||
cwd: "/tmp/project/nested",
|
||||
};
|
||||
const archivedOutsideAgent = {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
status: "closed",
|
||||
archivedAt: "2026-10-01T00:00:00.000Z",
|
||||
cwd: "/tmp/elsewhere",
|
||||
};
|
||||
let daemonAgents: (typeof agent)[] = [agent];
|
||||
const toEntry = (entry: typeof agent) => ({ agent: entry });
|
||||
// The fake daemon pages history two agents at a time.
|
||||
const historyPage = (cursor: string | undefined) => {
|
||||
const start = Number(cursor ?? 0);
|
||||
const end = start + 2;
|
||||
return {
|
||||
entries: daemonAgents.slice(start, end).map(toEntry),
|
||||
pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null },
|
||||
};
|
||||
};
|
||||
const cancelAgent = vi.fn(async () => {
|
||||
throw new Error("active run cancellation was not acknowledged");
|
||||
});
|
||||
const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) =>
|
||||
historyPage(options.page.cursor),
|
||||
);
|
||||
const deleteAgent = vi.fn(async () => undefined);
|
||||
const close = vi.fn(async () => undefined);
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
connectToDaemon: vi.fn(async () => ({
|
||||
fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })),
|
||||
fetchAgents: vi.fn(async () => historyPage(undefined)),
|
||||
fetchAgentHistory,
|
||||
fetchAgent: vi.fn(async () => ({ agent })),
|
||||
cancelAgent,
|
||||
deleteAgent,
|
||||
@@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({
|
||||
}));
|
||||
|
||||
describe("runDeleteCommand", () => {
|
||||
beforeEach(() => {
|
||||
daemonAgents = [agent];
|
||||
deleteAgent.mockClear();
|
||||
fetchAgentHistory.mockClear();
|
||||
});
|
||||
|
||||
it("force-deletes a running agent when graceful cancellation is refused", async () => {
|
||||
const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never);
|
||||
|
||||
expect(fetchAgentHistory).not.toHaveBeenCalled();
|
||||
expect(cancelAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(deleteAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(result.data).toEqual({
|
||||
@@ -38,4 +72,30 @@ describe("runDeleteCommand", () => {
|
||||
agentIds: [agent.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes archived agents on every history page with --all", async () => {
|
||||
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
|
||||
|
||||
const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never);
|
||||
|
||||
expect(result.data).toEqual({
|
||||
deletedCount: 3,
|
||||
agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes archived agents inside the directory with --cwd", async () => {
|
||||
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
|
||||
|
||||
const result = await runDeleteCommand(
|
||||
undefined,
|
||||
{ daemonTarget, cwd: "/tmp/project" },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result.data).toEqual({
|
||||
deletedCount: 2,
|
||||
agentIds: [agent.id, archivedAgent.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions {
|
||||
|
||||
export type AgentDeleteResult = SingleResult<DeleteResult>;
|
||||
|
||||
// History includes archived agents and agents whose provider is unavailable.
|
||||
async function fetchHistoryAgents(client: DaemonClient) {
|
||||
const agents: FetchAgentHistoryEntry["agent"][] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const payload = await client.fetchAgentHistory({
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
agents.push(...payload.entries.map((entry) => entry.agent));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export async function runDeleteCommand(
|
||||
id: string | undefined,
|
||||
options: AgentDeleteOptions,
|
||||
@@ -50,19 +65,16 @@ export async function runDeleteCommand(
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
const deletedIds: string[] = [];
|
||||
let agents: FetchAgentHistoryEntry["agent"][];
|
||||
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (options.all || options.cwd) {
|
||||
agents = await fetchHistoryAgents(client);
|
||||
if (!options.all) {
|
||||
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||
}
|
||||
} else {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id! });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
@@ -224,16 +225,7 @@ async function runFollowMode(
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
// Wait for interrupt
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
});
|
||||
|
||||
await waitForStop();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary
|
||||
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
|
||||
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
|
||||
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
|
||||
expect(stream.stderr).toContain("Agent not found: missing-agent");
|
||||
expect(stream.stderr).toContain("No agent found matching: missing-agent");
|
||||
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
|
||||
expect(restart.supervisorPid).toBe(launchB.pid);
|
||||
expect(restart.workerPid).not.toBe(beforeB.workerPid);
|
||||
@@ -503,7 +503,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy
|
||||
const launchA = await f.ok(["start", "--home", a]);
|
||||
await f.ok(["start", "--home", b]);
|
||||
const absent = await f.run(["logs", agentId, "--home", a]);
|
||||
expect(absent.stderr).toContain("Agent not found");
|
||||
expect(absent.stderr).toContain(`No agent found matching: ${agentId}`);
|
||||
follower = spawn(
|
||||
process.execPath,
|
||||
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
|
||||
@@ -522,7 +522,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy
|
||||
errors += data;
|
||||
});
|
||||
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
|
||||
expect(errors).not.toContain("Agent not found");
|
||||
expect(errors).not.toContain("No agent found matching");
|
||||
} finally {
|
||||
if (follower && follower.exitCode === null) {
|
||||
const exited = new Promise((resolve) => follower!.once("exit", resolve));
|
||||
|
||||
@@ -17,6 +17,15 @@ function inputFrom(value: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function claudeNotification(notificationType: string) {
|
||||
return JSON.stringify({
|
||||
session_id: "session-1",
|
||||
hook_event_name: "Notification",
|
||||
message: "Claude is waiting for your input",
|
||||
notification_type: notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
function ttyInput() {
|
||||
return {
|
||||
isTTY: true,
|
||||
@@ -123,7 +132,7 @@ describe("runHooksCommand", () => {
|
||||
const send = await runHook(
|
||||
claudeProvider.id,
|
||||
claudeProvider.events[4].event,
|
||||
inputFrom('{"reason":"idle_prompt"}'),
|
||||
inputFrom(claudeNotification("idle_prompt")),
|
||||
);
|
||||
|
||||
expectPostedState(send, "needs-input");
|
||||
@@ -131,11 +140,11 @@ describe("runHooksCommand", () => {
|
||||
|
||||
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
|
||||
"ignores Claude %s notifications",
|
||||
async (reason) => {
|
||||
async (notificationType) => {
|
||||
const send = await runHook(
|
||||
claudeProvider.id,
|
||||
claudeProvider.events[4].event,
|
||||
inputFrom(JSON.stringify({ reason })),
|
||||
inputFrom(claudeNotification(notificationType)),
|
||||
);
|
||||
|
||||
expect(send.calls).toEqual([]);
|
||||
|
||||
@@ -218,7 +218,7 @@ export function createPluginCommand(): Command {
|
||||
.description("Trust and install a plugin from a directory, Git repository, or npm package")
|
||||
.argument(
|
||||
"<source>",
|
||||
"Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source",
|
||||
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
|
||||
)
|
||||
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli";
|
||||
import { createCliParseArgv } from "./run";
|
||||
|
||||
const runModuleUrl = new URL("./run.ts", import.meta.url).href;
|
||||
|
||||
// Runs the CLI in a child process whose stdout pipe has no reader, as when the
|
||||
// program that launched it has already closed its end.
|
||||
async function runCliWithClosedStdout(
|
||||
argv: string[],
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
const script = `
|
||||
const { runCli } = await import(${JSON.stringify(runModuleUrl)});
|
||||
process.exitCode = await runCli(${JSON.stringify(argv)});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
process.stderr.write("still running\\n");
|
||||
`;
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
|
||||
cwd: path.dirname(fileURLToPath(import.meta.url)),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.destroy();
|
||||
let stderr = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve) => child.on("close", resolve));
|
||||
return { code, stderr };
|
||||
}
|
||||
|
||||
describe("runCli", () => {
|
||||
it("lets plugin update own --version while preserving global output options", async () => {
|
||||
const cli = createCli()
|
||||
@@ -72,6 +101,19 @@ describe("runCli", () => {
|
||||
).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]);
|
||||
});
|
||||
|
||||
it("finishes the command quietly when stdout is closed before it writes its output", async () => {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-"));
|
||||
|
||||
try {
|
||||
const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]);
|
||||
|
||||
expect(result.stderr).toBe("still running\n");
|
||||
expect(result.code).toBe(0);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("classifies existing unknown directories as open-project invocations", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
|
||||
const project = path.join(root, "repository");
|
||||
|
||||
@@ -36,7 +36,21 @@ export function createCliParseArgv(input: {
|
||||
return [...nodeArgv, ...cliArgv];
|
||||
}
|
||||
|
||||
// The program that started the CLI can close its end of stdout before the output
|
||||
// is written, as `paseo ls | head -1` or a launcher that discards output does.
|
||||
// Drop the output nobody reads and let the command finish, instead of surfacing
|
||||
// the failed write as an uncaught error.
|
||||
function ignoreClosedStdout(error: NodeJS.ErrnoException): void {
|
||||
if (error.code !== "EPIPE") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
|
||||
if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) {
|
||||
process.stdout.on("error", ignoreClosedStdout);
|
||||
}
|
||||
|
||||
const parseArgv = createCliParseArgv({
|
||||
argv,
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent
|
||||
// attach`) stop on Ctrl+C, a termination signal, or when the program reading
|
||||
// their output closes its end, as `| head` does. A closed reader shows up as
|
||||
// EPIPE on the next write to stdout, so the command stops at its next line of
|
||||
// output.
|
||||
export function waitForStop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
process.off("SIGINT", stop);
|
||||
process.off("SIGTERM", stop);
|
||||
process.stdout.off("error", stopOnClosedStdout);
|
||||
resolve();
|
||||
};
|
||||
const stopOnClosedStdout = (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EPIPE") {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
process.stdout.on("error", stopOnClosedStdout);
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
@@ -129,6 +130,28 @@ try {
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with stop\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 9: stop reports AGENT_NOT_FOUND for an unknown ID");
|
||||
const daemon = await startTestDaemon();
|
||||
try {
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"agent",
|
||||
"stop",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "stop should fail for an unknown ID");
|
||||
const { error } = JSON.parse(result.stderr);
|
||||
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
|
||||
assert.match(error.details, /paseo ls/);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
console.log("✓ stop reports AGENT_NOT_FOUND for an unknown ID\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
@@ -112,6 +113,28 @@ try {
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with delete\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 9: delete reports AGENT_NOT_FOUND for an unknown ID");
|
||||
const daemon = await startTestDaemon();
|
||||
try {
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"agent",
|
||||
"delete",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "delete should fail for an unknown ID");
|
||||
const { error } = JSON.parse(result.stderr);
|
||||
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
|
||||
assert.match(error.details, /paseo ls/);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
console.log("✓ delete reports AGENT_NOT_FOUND for an unknown ID\n");
|
||||
}
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Follow commands stop when nobody reads their output
|
||||
*
|
||||
* `paseo agent logs --follow` and `paseo agent attach` stream until they are
|
||||
* stopped. A program that closes its end of their stdout, as `| head` or a
|
||||
* launcher that discards output does, stops them the same way Ctrl+C does.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { connectToDaemon } from "../src/utils/client.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Follow Commands With Closed Stdout Tests ===\n");
|
||||
|
||||
const daemon = await startTestDaemon();
|
||||
const host = `127.0.0.1:${daemon.port}`;
|
||||
|
||||
try {
|
||||
// An agent created without a prompt stays idle, so no provider is called.
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.workDir,
|
||||
title: "closed stdout",
|
||||
});
|
||||
await client.close();
|
||||
|
||||
for (const args of [
|
||||
["agent", "logs", agent.id, "--follow"],
|
||||
["agent", "attach", agent.id],
|
||||
]) {
|
||||
const label = `paseo ${args.slice(0, 2).join(" ")}`;
|
||||
console.log(`Test: ${label} exits when its stdout reader is gone`);
|
||||
const result = await runPaseoCli(daemon, [...args, "--host", host], {
|
||||
closedStdout: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.stderr, "", `${label} should exit quietly`);
|
||||
assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`);
|
||||
console.log(`✓ ${label} exits when its stdout reader is gone\n`);
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== All follow commands with closed stdout tests passed ===");
|
||||
@@ -352,6 +352,8 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Close the reading end of the CLI's stdout, as a launcher that discards output does. */
|
||||
closedStdout?: boolean;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -380,6 +382,10 @@ export async function runPaseoCli(
|
||||
const stdout = createOutputCapture();
|
||||
const stderr = createOutputCapture();
|
||||
|
||||
if (options?.closedStdout) {
|
||||
proc.stdout?.destroy();
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
appendOutputCapture(stdout, data);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -81,8 +81,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.4",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@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",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/highlight": "0.11.0-beta.4",
|
||||
"@getpaseo/plugin": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -5034,6 +5034,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous
|
||||
|
||||
test("session config drift events update state through the stream channel", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
class ConfigEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
@@ -5046,6 +5047,7 @@ test("session config drift events update state through the stream channel", asyn
|
||||
clients: {
|
||||
codex: new ConfigEventClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
||||
});
|
||||
@@ -5100,6 +5102,7 @@ test("session config drift events update state through the stream channel", asyn
|
||||
|
||||
const agent = manager.getAgent(snapshot.id);
|
||||
expect(agent?.currentModeId).toBe("build");
|
||||
expect(agent?.config.modeId).toBe("build");
|
||||
expect(agent?.config.thinkingOptionId).toBe("high");
|
||||
expect(agent?.availableModes).toEqual([
|
||||
{ id: "plan", label: "Plan" },
|
||||
@@ -5111,6 +5114,10 @@ test("session config drift events update state through the stream channel", asyn
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
expect(streams.map((event) => event.type)).toEqual([]);
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.lastModeId).toBe("build");
|
||||
expect(persisted?.config?.modeId).toBe("build");
|
||||
});
|
||||
|
||||
test("setLabels merges and persists labels", async () => {
|
||||
@@ -9287,6 +9294,7 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
class PlanModeTestClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
resumedModeId: string | null | undefined;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
@@ -9296,14 +9304,19 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
return new PlanModeTestSession();
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumedModeId = overrides?.modeId;
|
||||
return new PlanModeTestSession();
|
||||
}
|
||||
}
|
||||
|
||||
const client = new PlanModeTestClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new PlanModeTestClient(),
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
@@ -9347,6 +9360,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
await manager.flush();
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.lastModeId).toBe("acceptEdits");
|
||||
|
||||
// The provider-managed switch must also reach config.modeId — otherwise a
|
||||
// reload resumes the session with the stale creation-time mode ("plan").
|
||||
expect(persisted?.config?.modeId).toBe("acceptEdits");
|
||||
const reloaded = await manager.reloadAgentSession(snapshot.id);
|
||||
expect(reloaded.config.modeId).toBe("acceptEdits");
|
||||
expect(client.resumedModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
|
||||
@@ -190,10 +190,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
};
|
||||
// lastModeId is the last live mode — it also covers provider-side switches
|
||||
// that never reach record.config.modeId.
|
||||
const modeId = record.lastModeId ?? record.config?.modeId;
|
||||
if (modeId != null) config.modeId = modeId;
|
||||
if (!record.config) {
|
||||
return config;
|
||||
}
|
||||
if (record.config.modeId != null) config.modeId = record.config.modeId;
|
||||
if (record.config.model != null) config.model = record.config.model;
|
||||
if (record.config.thinkingOptionId != null) {
|
||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||
@@ -3914,6 +3917,19 @@ export class AgentManager {
|
||||
return this.registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-side mode switches (ACP current_mode_update, in-session
|
||||
* commands, permission-driven transitions) must land in config.modeId too —
|
||||
* reloadAgentSession and the persisted record derive the resumed session's
|
||||
* mode from it, so leaving it stale silently downgrades the mode on resume.
|
||||
*/
|
||||
private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void {
|
||||
agent.currentModeId = modeId;
|
||||
if (modeId != null) {
|
||||
agent.config.modeId = modeId;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshSessionState(
|
||||
agent: ActiveManagedAgent,
|
||||
options?: { emit?: boolean },
|
||||
@@ -3926,7 +3942,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
try {
|
||||
agent.currentModeId = await agent.session.getCurrentMode();
|
||||
this.applyObservedMode(agent, await agent.session.getCurrentMode());
|
||||
} catch {
|
||||
agent.currentModeId = null;
|
||||
}
|
||||
@@ -4306,7 +4322,7 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
case "mode_changed":
|
||||
agent.currentModeId = event.currentModeId;
|
||||
this.applyObservedMode(agent, event.currentModeId);
|
||||
agent.availableModes = event.availableModes;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
|
||||
@@ -4322,7 +4338,7 @@ export class AgentManager {
|
||||
agent.cwd,
|
||||
);
|
||||
}
|
||||
agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
|
||||
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
|
||||
describe("agent timeline content", () => {
|
||||
test("limits terminal input to the tool-call content budget", () => {
|
||||
test("marks truncated plain-text tool content within the content budget", () => {
|
||||
const oversizedInput = "x".repeat(64 * 1024 + 1);
|
||||
|
||||
const item = limitAgentTimelineItemContent({
|
||||
@@ -19,6 +19,7 @@ describe("agent timeline content", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(limitAgentTimelineItemContent(item)).toEqual(item);
|
||||
expect(item).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "terminal-session-4242",
|
||||
@@ -27,7 +28,7 @@ describe("agent timeline content", () => {
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: "x".repeat(64 * 1024),
|
||||
text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,11 +42,12 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
const notice = "\n\n[Content truncated]";
|
||||
return {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -806,7 +806,6 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
emit: () => {},
|
||||
sessionLogger: createTestLogger(),
|
||||
terminalManager: null,
|
||||
archiveWorkspaceRecord: async () => {},
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user