mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db75059922 | ||
|
|
c7759713ec | ||
|
|
749fe3e5ae | ||
|
|
0d05584d04 | ||
|
|
42806cdbf9 | ||
|
|
15d8ba16a0 | ||
|
|
526ad1366f | ||
|
|
b5e9747e2a | ||
|
|
5f28b3cf8c | ||
|
|
42e221746c | ||
|
|
5c1be59b68 | ||
|
|
e18f9e8f43 | ||
|
|
70210f1071 | ||
|
|
a983ae0444 | ||
|
|
7dc2a3568c | ||
|
|
5375f43a05 | ||
|
|
8aec0787d2 | ||
|
|
5293ddac3f | ||
|
|
d3c76be9c1 | ||
|
|
d831c7bf33 | ||
|
|
8608525496 | ||
|
|
ac83aa0785 | ||
|
|
087cc71c00 | ||
|
|
3b5bbb139b | ||
|
|
ee0b0ef7c6 | ||
|
|
6a794b6572 | ||
|
|
1d0df5c0b7 | ||
|
|
73d25317a4 | ||
|
|
6c985769be | ||
|
|
bd3986d964 | ||
|
|
6166a7aca5 | ||
|
|
31cd18e748 | ||
|
|
05e94ab52e | ||
|
|
b5b43edd65 | ||
|
|
8498c74169 | ||
|
|
a52bdb106c | ||
|
|
70df09daeb | ||
|
|
e10f6d2e2c | ||
|
|
95774ba71f | ||
|
|
37b66897a9 | ||
|
|
4aa2f2817d | ||
|
|
bdb8e13221 | ||
|
|
06213e49bf | ||
|
|
d30e99c858 | ||
|
|
e939dd4c7a | ||
|
|
15f1431416 | ||
|
|
b701cb2dcd | ||
|
|
4893629ff5 | ||
|
|
e0a0cf990d | ||
|
|
7b0cace991 | ||
|
|
20a887f4cc | ||
|
|
a7c9c05bef | ||
|
|
1f5f5d384e | ||
|
|
695ecb2812 | ||
|
|
7a64eef491 | ||
|
|
9d9d9af993 | ||
|
|
29c198f955 | ||
|
|
318a84a570 | ||
|
|
364e6456e0 | ||
|
|
d070db15b3 | ||
|
|
4417d7c47a | ||
|
|
8332f4d1ff |
@@ -7,6 +7,7 @@ on:
|
||||
- "CHANGELOG.md"
|
||||
- "public-docs/**"
|
||||
- "packages/website/**"
|
||||
- "packages/protocol/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
@@ -29,7 +30,10 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
|
||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root
|
||||
|
||||
- name: Build protocol
|
||||
run: npm run build:protocol
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
|
||||
+113
@@ -1,5 +1,118 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
## 0.11.0-beta.2 - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
|
||||
## 0.11.0-beta.1 - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
||||
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
|
||||
- Added provider option defaults in `config.json` under `agents.providers.<id>.options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
|
||||
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
|
||||
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
|
||||
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
|
||||
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
|
||||
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
|
||||
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684))
|
||||
- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686))
|
||||
- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758))
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
||||
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
||||
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
||||
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
|
||||
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
|
||||
- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
|
||||
|
||||
## 0.10.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- 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))
|
||||
|
||||
## 0.10.2 - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||
|
||||
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
|
||||
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
|
||||
|
||||
## Repository map
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -42,7 +42,7 @@
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
@@ -68,6 +68,8 @@ You need at least one agent CLI installed and configured with your credentials:
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
- [Pi](https://pi.dev)
|
||||
- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity)
|
||||
- [Muse Code](https://paseo.sh/docs/muse-code)
|
||||
|
||||
### Desktop app (recommended)
|
||||
|
||||
|
||||
@@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds:
|
||||
The limit applies independently to every provider refresh and covers availability plus the entire
|
||||
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
|
||||
|
||||
## Provider options
|
||||
|
||||
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
|
||||
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
|
||||
`Record<string, unknown>` values: the provider owns validation and application.
|
||||
Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error.
|
||||
Providers without option handling ignore the record.
|
||||
|
||||
The daemon deep-merges the agent's options over the provider defaults at each
|
||||
session launch or resume. Plain objects merge recursively; arrays, scalars, and
|
||||
`null` replace the base value. Only the agent's own options are persisted, so edits
|
||||
to provider defaults apply on the next launch or resume. A profile inherits its
|
||||
base provider's options unless it sets its own options record.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"codex": {
|
||||
"options": { "sandbox_mode": "read-only", "approval_policy": "never" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for
|
||||
per-agent examples and provider-specific keys.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
@@ -391,7 +420,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
"XDG_CONFIG_HOME": "~/.config/omp-work",
|
||||
"XDG_STATE_HOME": "~/.local/state/omp-work"
|
||||
},
|
||||
"params": {
|
||||
"options": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"rpcTimeoutMs": 60000,
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
@@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
||||
`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
@@ -416,7 +445,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
"extends": "pi",
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"options": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions",
|
||||
"rpcTimeoutMs": 60000
|
||||
}
|
||||
@@ -426,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
}
|
||||
```
|
||||
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
||||
|
||||
---
|
||||
|
||||
@@ -484,7 +513,7 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "acp"],
|
||||
"params": {
|
||||
"options": {
|
||||
"supportsMcpServers": false
|
||||
}
|
||||
}
|
||||
@@ -505,7 +534,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
|
||||
ACP agents execute filesystem operations in their own environment by default,
|
||||
while terminal operations run through Paseo on the host. To customize which
|
||||
operations Paseo handles, configure client capabilities in provider params:
|
||||
operations Paseo handles, configure client capabilities in provider options:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params:
|
||||
"extends": "acp",
|
||||
"label": "Container Agent",
|
||||
"command": ["container-agent", "acp"],
|
||||
"params": {
|
||||
"options": {
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": false,
|
||||
@@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields:
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
||||
| `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
|
||||
+4
-2
@@ -182,8 +182,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
||||
Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
|
||||
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed
|
||||
skill directories and keeps config plus filesystem convergence behind one serialized owner.
|
||||
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared
|
||||
`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind
|
||||
one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from
|
||||
Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files.
|
||||
|
||||
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
|
||||
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
|
||||
|
||||
+1
-1
@@ -217,7 +217,7 @@ Changing state must not move the layout. A row that grows when its badge arrives
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ fixed-target labels and icons from that registration, filter by host, and never
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
|
||||
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||
Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons,
|
||||
so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
@@ -41,11 +43,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T
|
||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
|
||||
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
|
||||
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
|
||||
focus.
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
|
||||
@@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
and restoration then silently do nothing. Callers may use callback refs on the wrapper, which
|
||||
forwards commands to the current Gorhom handle.
|
||||
|
||||
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
|
||||
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
|
||||
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
|
||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
|
||||
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
|
||||
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
|
||||
|
||||
## Gotcha 7 — A sheet cannot read context from its call site
|
||||
|
||||
React cannot copy contexts reflectively, so the only way across the teleport in
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
@@ -34,7 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
@@ -191,6 +191,8 @@ its own.
|
||||
[floating-panels.md](floating-panels.md).
|
||||
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
|
||||
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
|
||||
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
|
||||
+58
-24
@@ -88,34 +88,64 @@ never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
|
||||
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
|
||||
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
|
||||
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
|
||||
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
|
||||
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
|
||||
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
|
||||
`server/`, and optional client entry and icon. Add the plugin ID to `builtinPlugins` in
|
||||
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
|
||||
checks cover that registry. Unlisted directories do not load.
|
||||
|
||||
Desktop packaging ships the entire built-in plugin directory as an external resource,
|
||||
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
|
||||
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
|
||||
Built-ins can import only host modules (`@getpaseo/plugin/*`, `zod`) and Node built-ins:
|
||||
outside the archive, nothing resolves an npm dependency, and the dist build test cannot catch
|
||||
one because it resolves through the repository's `node_modules`.
|
||||
The packaged-app smoke check requires every listed built-in to start without relying on
|
||||
account credentials.
|
||||
|
||||
Built-ins run in process and remain active independently of `pluginsEnabled`. They are
|
||||
absent from the installed plugin list and source configuration; their client bundles
|
||||
appear in the plugin catalog. Editing one in development requires a daemon restart.
|
||||
Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
|
||||
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
|
||||
availability and a diagnostic after the daemon resolves the executable. Configure command,
|
||||
environment, or enablement overrides under `agents.providers.muse`. Omit `extends` to keep
|
||||
the bundled integration; an entry with `extends` shadows it with a custom provider. See
|
||||
[provider contributions](#contribute-a-provider) for the contract and
|
||||
[Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations.
|
||||
|
||||
## Install from a registry
|
||||
|
||||
`paseo plugin install owner/slug` installs the registry's reviewed artifact. 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.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
`pluginRegistries: { "host": { "authorization": "Bearer token" } }`.
|
||||
Restart your daemon after changing these startup settings. Credentials go only to the registry,
|
||||
never artifact hosts or redirects. Git/npm use their own host authentication.
|
||||
|
||||
The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md)
|
||||
owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||
directory always wins over shorthand resolution.
|
||||
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
|
||||
still wins over source resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin ls
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
paseo plugin install github:owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install github:owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
|
||||
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial
|
||||
branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD
|
||||
and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
|
||||
## Managed source ownership
|
||||
|
||||
@@ -420,7 +450,11 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `providerOptions` config object.
|
||||
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
|
||||
It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
@@ -449,7 +483,7 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
@@ -463,7 +497,7 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
|
||||
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
|
||||
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
|
||||
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
|
||||
are rows between Add project and the footer's icon row. The icon row (Hosts,
|
||||
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
|
||||
button.
|
||||
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
|
||||
|
||||
+21
-54
@@ -6,7 +6,7 @@ server integrations.
|
||||
## Plugin providers
|
||||
|
||||
Keep a bundled provider in `plugins/<id>/` and register it through
|
||||
`@getpaseo/plugin/server/provider`. Antigravity follows this pattern. Built-in loading and SDK
|
||||
`@getpaseo/plugin/server/provider`. Antigravity and Muse Code follow this pattern. Built-in loading and SDK
|
||||
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
|
||||
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
|
||||
|
||||
@@ -15,52 +15,22 @@ executable resolution and applies `agents.providers.<provider-id>.command` and `
|
||||
connecting. Register the provider's icon with the plugin rather than adding it to the app's
|
||||
provider icon map. You do not need a core manifest entry or provider factory.
|
||||
|
||||
For Antigravity installation, modes, and limitations, see
|
||||
[Supported providers](../public-docs/supported-providers.md#antigravity).
|
||||
| Provider | Transport | Setup and limitations |
|
||||
| ----------- | ------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| Antigravity | Installed `agy` CLI | [Antigravity](../public-docs/supported-providers.md#antigravity) |
|
||||
| Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) |
|
||||
|
||||
## Provider-native session options
|
||||
|
||||
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
|
||||
top-level provider options.
|
||||
The provider owns validation and application of the opaque record in
|
||||
`AgentSessionConfig.providerOptions`. The registry supplies the effective options
|
||||
at session startup. See [provider configuration](custom-providers.md#provider-options)
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
- **Codex:** `approval_policy`, `sandbox_mode`,
|
||||
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
|
||||
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
|
||||
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
|
||||
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
|
||||
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
|
||||
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
|
||||
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
|
||||
application policy, not an OS sandbox.
|
||||
|
||||
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||
server and tool identity without approving native tools.
|
||||
|
||||
## Core adapter patterns
|
||||
|
||||
@@ -85,7 +55,7 @@ model; it does not override the model list returned by a resolver.
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
@@ -101,7 +71,9 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path` `builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence.
|
||||
|
||||
The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
|
||||
|
||||
@@ -215,15 +187,10 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
## Usage sources
|
||||
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
|
||||
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
|
||||
|
||||
### Credentials are read only
|
||||
|
||||
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
contract, account and window identity, provider-derived period names, login fallback, and
|
||||
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
|
||||
renders their names and resolves pins without provider-specific duration guesses.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
|
||||
Generated
+58
-41
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -6330,6 +6330,15 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@getpaseo/app": {
|
||||
"resolved": "packages/app",
|
||||
"link": true
|
||||
@@ -26261,17 +26270,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react-native": {
|
||||
"version": "0.546.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
|
||||
"integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -36415,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36480,7 +36478,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
@@ -37335,6 +37333,23 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/lucide-react-native": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz",
|
||||
"integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
@@ -37481,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/server": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/server": "0.11.0-beta.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37733,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37747,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37991,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38887,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38916,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38933,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38956,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38983,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39201,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/highlight": "0.10.0",
|
||||
"@getpaseo/plugin": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/highlight": "0.11.0-beta.3",
|
||||
"@getpaseo/plugin": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40131,10 +40146,11 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
@@ -40149,7 +40165,8 @@
|
||||
"remark-directive": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"wrangler": "^4.75.0"
|
||||
"wrangler": "^4.75.0",
|
||||
"@getpaseo/protocol": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -41736,7 +41753,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.0.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1242,6 +1242,7 @@ test.describe("Agent message submission", () => {
|
||||
await gate.waitForHeldServerMessage("send_agent_message_response");
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
gate.releaseHeldServerMessage("send_agent_message_response");
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect } from "@playwright/test";
|
||||
import {
|
||||
findScrollJumps,
|
||||
expectImageSpaceReserved,
|
||||
type ScrollFrame,
|
||||
observeTimelinePages,
|
||||
openOnlyTimelineTail,
|
||||
recordUpwardTraversal,
|
||||
reportScrollJumps,
|
||||
scrollCadences,
|
||||
withVariedTimeline,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
for (const cadence of scrollCadences) {
|
||||
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await withVariedTimeline(async (agent, newestPrompt) => {
|
||||
const pages = observeTimelinePages(page, agent.agentId);
|
||||
await openOnlyTimelineTail(page, agent, newestPrompt, pages);
|
||||
const frames = await recordUpwardTraversal(page, cadence, testInfo);
|
||||
await reportScrollJumps(page, testInfo, frames, pages);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
|
||||
await expectImageSpaceReserved(page, testInfo);
|
||||
});
|
||||
@@ -84,6 +84,18 @@ async function openModelSelector(page: Page) {
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function dismissStackedModelPickerWithBackdrop(page: Page) {
|
||||
await page
|
||||
.getByTestId("agent-controls-settings-list")
|
||||
.getByRole("button", { name: /Select model/ })
|
||||
.click();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
|
||||
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openAndCloseTabSwitcherTwice(page: Page) {
|
||||
await openTabSwitcher(page);
|
||||
await closeBottomSheetWithBackdrop(page);
|
||||
@@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
await dismissStackedModelPickerWithBackdrop(page);
|
||||
|
||||
await page.getByTestId("model-search-all-input").click();
|
||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||
await expect(model).toBeVisible({
|
||||
|
||||
@@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const explorer = await openExplorerWithKeyboard(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||
/** Scope the shared tab row to the Explorer dock. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "explorer-sidebar-tab-rail");
|
||||
return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
|
||||
}
|
||||
|
||||
function mainTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row");
|
||||
return page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true });
|
||||
}
|
||||
|
||||
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||
@@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => {
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
@@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
|
||||
await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Split pane right/ }).click();
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, draftTabChip(page), {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
|
||||
await expect(mainTabRow(page)).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectNewLauncher(page: Page): Promise<void> {
|
||||
|
||||
@@ -3,20 +3,20 @@ import {
|
||||
withExplorerPanelPlugins,
|
||||
expectWorkspacePanelsInExplorerLauncher,
|
||||
openWorkspacePanelFromExplorerMenu,
|
||||
expectIndependentExplorerPanelToggles,
|
||||
expectIndependentExplorerPanelTabs,
|
||||
expectExplorerPanelWithFocusedAgent,
|
||||
} from "../support/helpers/explorer-plugin-menu";
|
||||
|
||||
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
|
||||
test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withExplorerPanelPlugins(page, async (workspace) => {
|
||||
await test.step("the New Tab launcher offers compatible workspace panels", () =>
|
||||
await test.step("the + menu offers compatible workspace panels", () =>
|
||||
expectWorkspacePanelsInExplorerLauncher(page));
|
||||
await test.step("the rail menu offers the same workspace panels", () =>
|
||||
await test.step("the + menu opens a workspace panel in Explorer", () =>
|
||||
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
|
||||
await test.step("different panels and plugins keep independent selection and closing", () =>
|
||||
expectIndependentExplorerPanelToggles(page, workspace));
|
||||
expectIndependentExplorerPanelTabs(page, workspace));
|
||||
await test.step("focusing an agent does not add agent panels to Explorer", () =>
|
||||
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
pressNewTabShortcut,
|
||||
expectTabTitleFits,
|
||||
} from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
ensureExplorerSidebar,
|
||||
@@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectExplorerActiveTabForeground(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
await openFilesPanel(page);
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await main.hover();
|
||||
const activeMainLabel = main
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.locator('[aria-selected="true"]')
|
||||
.getByText("New tab", { exact: true });
|
||||
const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color);
|
||||
const activeFiles = explorerSidebar(page).getByRole("button", {
|
||||
name: "Browse workspace files",
|
||||
exact: true,
|
||||
});
|
||||
await expect(activeFiles).toHaveAttribute("aria-selected", "true");
|
||||
await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground);
|
||||
}
|
||||
|
||||
test.describe("Explorer sidebar", () => {
|
||||
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
|
||||
page,
|
||||
@@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => {
|
||||
.count();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1);
|
||||
|
||||
await openFilesPanel(page);
|
||||
await expectExplorerActiveTabForeground(page);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||
await explorer.getByTestId("workspace-tab-changes_tree").click();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
@@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function launchExplorerPanel(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
name: string,
|
||||
) {
|
||||
const explorer = explorerSidebar(page);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click();
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function closeExplorerFilesFromContextMenu(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
const explorer = explorerSidebar(page);
|
||||
const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true });
|
||||
await files.hover();
|
||||
await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0);
|
||||
await explorer.getByTestId("workspace-tab-changes_tree").hover();
|
||||
await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0);
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(files).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectWorkspaceCloseOnHover(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]');
|
||||
const close = main.getByRole("button", { name: "Close", exact: true });
|
||||
await explorerSidebar(page).hover();
|
||||
await expect(close.locator("..")).toHaveCSS("opacity", "0");
|
||||
await tab.hover();
|
||||
await expect(close.locator("..")).toHaveCSS("opacity", "1");
|
||||
}
|
||||
|
||||
async function closeOtherExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const files = explorerSidebar(page).getByRole("button", {
|
||||
name: "Browse workspace files",
|
||||
exact: true,
|
||||
});
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
const confirmation = page.waitForEvent("dialog").then((dialog) => {
|
||||
expect(dialog.message()).toContain("close 1 tab");
|
||||
return dialog.accept();
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click();
|
||||
await confirmation;
|
||||
}
|
||||
|
||||
test("Explorer keeps Files and Changes close actions in the context menu", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count();
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expectTabTitleFits(page, "Files", { min: 64, max: 90 });
|
||||
|
||||
await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => {
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
|
||||
const entries = await menu.getByRole("menuitem").allTextContents();
|
||||
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
|
||||
entries.findIndex((entry) => entry.startsWith("Diff")),
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await main.getByTestId("workspace-new-tab-button").click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => {
|
||||
await expectWorkspaceCloseOnHover(page);
|
||||
await closeExplorerFilesFromContextMenu(page);
|
||||
await launchExplorerPanel(page, "Files");
|
||||
await expect(
|
||||
explorer.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("standard bulk-close menus affect only Explorer tabs", async () => {
|
||||
await closeOtherExplorerTabs(page);
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount(
|
||||
mainTabsBefore,
|
||||
);
|
||||
await launchExplorerPanel(page, "Changes");
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Cmd+T still belongs to the focused workspace pane", async () => {
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(
|
||||
main.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await testInfo.attach("shared-explorer-tabs", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function dragAgentIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
agentId: string,
|
||||
) {
|
||||
const source = page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
if (!sourceBox || !destinationBox)
|
||||
throw new Error("Tab drag requires visible source and destination");
|
||||
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 12, start.y + 4);
|
||||
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("agents can still be dragged into Explorer when absent from its + menu", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Dragged agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await dragAgentIntoExplorer(page, agent.id);
|
||||
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await testInfo.attach("agent-in-explorer", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||
).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
@@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -279,7 +279,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(2);
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import {
|
||||
buildPairingLink,
|
||||
cancelHostConfirmation,
|
||||
connectToConfirmedHost,
|
||||
expectHostConfirmationFor,
|
||||
expectHostInHostPicker,
|
||||
hostConfirmation,
|
||||
openPairingLink,
|
||||
saveLinkedHostBeforeLoad,
|
||||
type LinkedHost,
|
||||
} from "../support/helpers/host-confirmation";
|
||||
|
||||
// An unreachable relay: the link saves the host without connecting first.
|
||||
const linkedHost: LinkedHost = {
|
||||
serverId: "srv_link_confirmation",
|
||||
relayEndpoint: "127.0.0.1:59998",
|
||||
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
|
||||
};
|
||||
|
||||
test("a pairing link for a new host asks before saving it", async ({ page }) => {
|
||||
const link = buildPairingLink(linkedHost);
|
||||
|
||||
await test.step("Cancel saves nothing and stays on the page", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await cancelHostConfirmation(page);
|
||||
await expectAppRoute(page, "/settings/general");
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
|
||||
});
|
||||
|
||||
await test.step("Connect saves the host and opens a project", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await connectToConfirmedHost(page);
|
||||
await expectAppRoute(page, "/open-project");
|
||||
await openSettings(page);
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pairing link for a saved host connects without asking", async ({ page }) => {
|
||||
await saveLinkedHostBeforeLoad(page, linkedHost);
|
||||
|
||||
await openPairingLink(page, buildPairingLink(linkedHost));
|
||||
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => {
|
||||
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
await flow.revealMobileEntryPoint();
|
||||
await flow.openGlobally();
|
||||
|
||||
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
||||
@@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
|
||||
await test.step("the mobile sidebar exposes import in its footer", async () => {
|
||||
await flow.revealMobileEntryPoint();
|
||||
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
|
||||
await test.step("the new workspace screen offers import at the top", async () => {
|
||||
await flow.revealNewWorkspaceEntryPoint();
|
||||
await capture(page, testInfo, "01-mobile-new-workspace-import.png");
|
||||
});
|
||||
|
||||
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
|
||||
await flow.openGlobally();
|
||||
await flow.openFromNewWorkspace();
|
||||
await flow.expectScope("Sessions on", false);
|
||||
await flow.expectRows({
|
||||
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
|
||||
|
||||
@@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
@@ -36,9 +36,18 @@ function ModalBody({ onSaved }) {
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Surface() {
|
||||
function Surface({ playAudio }) {
|
||||
const [audioStatus, setAudioStatus] = useState("Audio ready");
|
||||
async function play(base64) {
|
||||
setAudioStatus("Playing audio");
|
||||
try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); }
|
||||
catch { setAudioStatus("Audio rejected"); }
|
||||
}
|
||||
const [open, setOpen] = useState(false);
|
||||
return <View>
|
||||
<Text>{audioStatus}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => play(AUDIO_BASE64)}><Text>Play plugin audio</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => play("UklGRg==")}><Text>Play invalid audio</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Icon name="Pencil" size={18} />
|
||||
@@ -59,7 +68,7 @@ function Surface() {
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSurface("main", () => <Surface playAudio={plugin.playAudio} />);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Host UI",
|
||||
@@ -123,7 +132,11 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav"));
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -131,6 +144,12 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
await useNonCompactLayout(page);
|
||||
await openHostUiPlugin(page);
|
||||
|
||||
await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => {
|
||||
await playPluginAudio(page, "Play plugin audio", "Audio finished");
|
||||
await playPluginAudio(page, "Play invalid audio", "Audio rejected");
|
||||
await playPluginAudio(page, "Play plugin audio", "Audio finished");
|
||||
});
|
||||
|
||||
await test.step("non-compact layouts use a centered dialog", async () => {
|
||||
await openPluginModal(page);
|
||||
await expectCenteredPluginDialog(page);
|
||||
@@ -153,3 +172,8 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function playPluginAudio(page: Page, button: string, result: string) {
|
||||
await page.getByRole("button", { name: button, exact: true }).click();
|
||||
await expect(page.getByText(result, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -40,22 +42,21 @@ async function qaScreenshot(page: Page, name: string, area?: Locator) {
|
||||
else await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */
|
||||
/** Footer rows sit above the fixed bottom line, spanning it from Add project to Settings. */
|
||||
async function expectFooterRow(page: Page, row: Locator) {
|
||||
// Polled: on compact the drawer is still sliding in when the row first shows.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
|
||||
const hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!;
|
||||
const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!;
|
||||
const box = (await row.boundingBox())!;
|
||||
return {
|
||||
belowAddProject: box.y >= addProject.y + addProject.height,
|
||||
aboveIconRow: box.y + box.height <= hosts.y,
|
||||
aboveBottomLine: box.y + box.height <= addProject.y,
|
||||
alignedLeft: Math.abs(box.x - addProject.x) < 1,
|
||||
fullWidth: Math.abs(box.width - addProject.width) < 1,
|
||||
alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1,
|
||||
};
|
||||
})
|
||||
.toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true });
|
||||
.toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true });
|
||||
}
|
||||
|
||||
function botRow(page: Page, rowId: string): Locator {
|
||||
@@ -153,6 +154,8 @@ test.describe("Plugin sidebar items", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
// The Usage item shows only with summary data.
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
@@ -188,7 +191,7 @@ test.describe("Plugin sidebar items", () => {
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the footer row renders as a row between Add project and the icon row", async () => {
|
||||
await test.step("the footer row renders as a row above the bottom line", async () => {
|
||||
await expect(sync).toHaveAccessibleName("Sync");
|
||||
await expectFooterRow(page, sync);
|
||||
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
expectFooterIconRow,
|
||||
expectFooterSeparator,
|
||||
hoverFooterAddProject,
|
||||
footerScreenshot,
|
||||
expectFooterItemHidden,
|
||||
expectFooterOrder,
|
||||
expectFooterSettingsKeys,
|
||||
@@ -21,6 +31,50 @@ import {
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await expectFooterIconRow(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await footerScreenshot(page, "footer-desktop-with-rows");
|
||||
await hoverFooterAddProject(page);
|
||||
await footerScreenshot(page, "footer-desktop-add-project-tooltip");
|
||||
await openAddProjectFlow(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterSettingsKeys(page, ["usage"]);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await expectFooterIconRow(page);
|
||||
await footerScreenshot(page, "footer-desktop-without-rows");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await expectFooterIconRow(page);
|
||||
await footerScreenshot(page, "footer-compact-without-rows");
|
||||
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
|
||||
await expect(page.getByTestId("sidebar-add-project-tooltip")).toHaveCount(0);
|
||||
await footerScreenshot(page, "footer-compact-add-project-hover");
|
||||
await openAddProjectFlow(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await leaveSettings(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await expectFooterIconRow(page);
|
||||
await footerScreenshot(page, "footer-compact-with-rows");
|
||||
});
|
||||
|
||||
test.describe("Sidebar items in Appearance settings", () => {
|
||||
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
@@ -130,6 +184,8 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
|
||||
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
// The Usage item shows only with summary data.
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
// Keys for the fixed footer icon buttons are ignored.
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "help", visible: false },
|
||||
@@ -147,6 +203,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
await leaveSettings(page);
|
||||
await expectFooterOrder(page, ["usage", syncKey]);
|
||||
await expectFooterIconRow(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
});
|
||||
|
||||
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
|
||||
@@ -160,6 +217,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
|
||||
).toBeVisible();
|
||||
await expectFooterIconRow(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
});
|
||||
|
||||
await test.step("the rows keep that shape across a reload", async () => {
|
||||
@@ -169,6 +227,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expectFooterIconRow(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
});
|
||||
|
||||
@@ -572,18 +572,21 @@ test.describe("Half-screen desktop layout", () => {
|
||||
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const DRAG_DISTANCE = -40;
|
||||
const TOLERANCE_PX = 2;
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function dividerX(divider: Locator): Promise<number> {
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
return box.x;
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Split pane drag tracking", () => {
|
||||
test("a divider moves as far as the mouse in the main row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
for (const expectedDividers of [1, 2, 3]) {
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
||||
}
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
const divider = splitDividers(page).nth(index);
|
||||
const before = await dividerX(divider);
|
||||
await dragDivider(divider, DRAG_DISTANCE);
|
||||
await expect
|
||||
.poll(async () => Math.abs((await dividerX(divider)) - before - DRAG_DISTANCE), {
|
||||
message: `divider ${index + 1} should stay under the mouse`,
|
||||
})
|
||||
.toBeLessThanOrEqual(TOLERANCE_PX);
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -199,6 +199,49 @@ async function quitVim(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
interface TerminalChord {
|
||||
key: string;
|
||||
bytes: string;
|
||||
}
|
||||
|
||||
async function openKeyboardCapture(page: Page) {
|
||||
await test.step("Open a real terminal process that reports received bytes", async () => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Keyboard bytes",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
let received = Buffer.alloc(0);
|
||||
process.stdin.on('data', chunk => {
|
||||
received = Buffer.concat([received, chunk]);
|
||||
process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n');
|
||||
});
|
||||
console.log('KEYBOARD_READY');
|
||||
`,
|
||||
],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY");
|
||||
});
|
||||
}
|
||||
|
||||
async function expectTerminalChords(page: Page, chords: TerminalChord[]) {
|
||||
let received = "";
|
||||
for (const chord of chords) {
|
||||
await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => {
|
||||
await pressTerminalShortcut(page, chord.key);
|
||||
// A printable sentinel proves delivery even for chords expected to send nothing.
|
||||
await input(page).pressSequentially("x");
|
||||
received += Buffer.from(chord.bytes + "x").toString("hex");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`);
|
||||
});
|
||||
}
|
||||
await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page));
|
||||
await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") });
|
||||
}
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
|
||||
@@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => {
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("encodes editing chords without confusing Control and Command", async ({ page }) => {
|
||||
await openKeyboardCapture(page);
|
||||
await expectTerminalChords(page, [
|
||||
{ key: "Meta+ArrowLeft", bytes: "\x01" },
|
||||
{ key: "Meta+ArrowRight", bytes: "\x05" },
|
||||
{ key: "Alt+ArrowLeft", bytes: "\x1bb" },
|
||||
{ key: "Alt+ArrowRight", bytes: "\x1bf" },
|
||||
{ key: "Meta+Backspace", bytes: "\x15" },
|
||||
{ key: "Control+c", bytes: "\x03" },
|
||||
{ key: "Control+a", bytes: "\x01" },
|
||||
{ key: "Control+e", bytes: "\x05" },
|
||||
{ key: "Control+v", bytes: "\x16" },
|
||||
{ key: "Control+f", bytes: "\x06" },
|
||||
{ key: "ArrowLeft", bytes: "\x1b[D" },
|
||||
{ key: "ArrowRight", bytes: "\x1b[C" },
|
||||
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
|
||||
{ key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" },
|
||||
]);
|
||||
await pressTerminalShortcut(page, "Meta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
@@ -232,7 +297,40 @@ test.describe("macOS terminal shortcuts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function useLinuxKeyboardPlatform(page: Page) {
|
||||
// A user agent alone leaves navigator.platform as MacIntel on macOS hosts.
|
||||
// Emulate both browser identity fields before the app reads its keyboard policy.
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => {
|
||||
await useLinuxKeyboardPlatform(page);
|
||||
await openKeyboardCapture(page);
|
||||
await expectTerminalChords(page, [
|
||||
{ key: "Meta+ArrowLeft", bytes: "" },
|
||||
{ key: "Meta+ArrowRight", bytes: "" },
|
||||
{ key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" },
|
||||
{ key: "Alt+ArrowRight", bytes: "\x1b[1;3C" },
|
||||
{ key: "Meta+Backspace", bytes: "\x7f" },
|
||||
{ key: "Control+c", bytes: "\x03" },
|
||||
{ key: "Control+a", bytes: "\x01" },
|
||||
{ key: "Control+e", bytes: "\x05" },
|
||||
{ key: "Control+ArrowLeft", bytes: "\x1b[1;5D" },
|
||||
{ key: "Control+ArrowRight", bytes: "\x1b[1;5C" },
|
||||
{ key: "Control+Backspace", bytes: "\x08" },
|
||||
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
|
||||
]);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
|
||||
await useLinuxKeyboardPlatform(page);
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
|
||||
@@ -1,10 +1,65 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installUsageReportsFixture,
|
||||
type UsageListRequest,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
refreshAllUsage,
|
||||
togglePin,
|
||||
usageItem,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`);
|
||||
test.use({
|
||||
e2eDaemonEnvironment: {
|
||||
HOME: emptyHome,
|
||||
USERPROFILE: emptyHome,
|
||||
CODEX_HOME: emptyHome,
|
||||
CLAUDE_CONFIG_DIR: emptyHome,
|
||||
XDG_DATA_HOME: emptyHome,
|
||||
PI_CODING_AGENT_DIR: emptyHome,
|
||||
COPILOT_TOKEN: "",
|
||||
GITHUB_TOKEN: "",
|
||||
GITHUB_PAT: "",
|
||||
CURSOR_ACCESS_TOKEN: "",
|
||||
CURSOR_TOKEN: "",
|
||||
GROK_API_KEY: "",
|
||||
GROK_TOKEN: "",
|
||||
KIMI_TOKEN: "",
|
||||
KIMI_API_KEY: "",
|
||||
KIMI_CODE_HOME: emptyHome,
|
||||
MINIMAX_API_KEY: "",
|
||||
ZAI_API_KEY: "",
|
||||
GLM_API_KEY: "",
|
||||
},
|
||||
});
|
||||
|
||||
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
|
||||
return usage.listRequests().filter((request) => request.forceRefresh);
|
||||
}
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
@@ -20,15 +75,25 @@ async function qaScreenshot(page: Page, name: string) {
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
/** The host selector names the host shown, even when it is the only one. */
|
||||
async function shownHostLabel(page: Page): Promise<string> {
|
||||
const selector = page.locator('[data-testid="usage-host-switcher"]:visible');
|
||||
await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 });
|
||||
return (await selector.innerText()).trim();
|
||||
function hostFilter(page: Page) {
|
||||
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
|
||||
}
|
||||
|
||||
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
return {
|
||||
id: `${sourceId}:a`,
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId,
|
||||
sourceLabel: `${sourceId} plan`,
|
||||
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] },
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test("opens from the sidebar on the host's reports, naming the one host", async ({ page }) => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
@@ -51,7 +116,10 @@ test.describe("usage screen", () => {
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: { status: "unavailable", windows: [] },
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -64,10 +132,7 @@ test.describe("usage screen", () => {
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName(
|
||||
`Usage host: ${hostLabel}`,
|
||||
);
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
@@ -103,13 +168,13 @@ test.describe("usage screen", () => {
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[alpha(31, twoHoursAgo()), beta],
|
||||
() => [
|
||||
alpha(58, new Date().toISOString()),
|
||||
{ ...beta, fetchedAt: new Date().toISOString() },
|
||||
],
|
||||
(request) =>
|
||||
request.forceRefresh
|
||||
? [alpha(58, new Date().toISOString())]
|
||||
: [alpha(31, twoHoursAgo()), beta],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -120,11 +185,9 @@ test.describe("usage screen", () => {
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: undefined },
|
||||
{ forceRefresh: true, reportIds: ["alpha:a"] },
|
||||
]);
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
@@ -167,15 +230,257 @@ test.describe("usage screen", () => {
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
|
||||
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, {
|
||||
exact: true,
|
||||
}),
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(420_000);
|
||||
const primaryServerId = getServerId();
|
||||
const secondary = await startIsolatedHostDaemon(
|
||||
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
||||
);
|
||||
try {
|
||||
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
||||
await installUsageReportsFixture(page, {
|
||||
port: secondary.port,
|
||||
lists: [[weeklyReport("beta", 12)]],
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondary.serverId,
|
||||
label: "Secondary box",
|
||||
port: secondary.port,
|
||||
});
|
||||
|
||||
// Nothing picked and no workspace open: the first host.
|
||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||
await usageItem(page).click();
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-screen-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondary.serverId,
|
||||
label: "Secondary box",
|
||||
port: secondary.port,
|
||||
});
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const loginWindows: UsageReportEntry["report"] = {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 },
|
||||
{ id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 },
|
||||
],
|
||||
};
|
||||
|
||||
test("expired login refreshes to windows with visible pin toggles", async ({ page }) => {
|
||||
const fixture = await installLoginUsage({
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-expired-login");
|
||||
await fixture.setReport(loginWindows);
|
||||
await refreshLoginUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
||||
await expect(row).toBeChecked();
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await hoverUsageWindow(page, "Weekly");
|
||||
await expect(page.getByText("Unpin", { exact: true })).toBeVisible();
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await qaScreenshot(page, "usage-pin-hover");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await hoverUsageWindow(page, "Weekly");
|
||||
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
await qaScreenshot(page, "usage-pin-unselected");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
const fixture = await installLoginUsage(loginWindows);
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(1)).toHaveCSS("opacity", "1");
|
||||
await qaScreenshot(page, "usage-pin-compact");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const shape of ["seven-day-only", "Spark"] as const) {
|
||||
test(`Codex ${shape} windows use provider durations on the card and sidebar`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const reset = Math.floor(Date.now() / 1000) + 5 * 86400;
|
||||
const source = await installCodexWindowUsage({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 11, limit_window_seconds: 604800, reset_at: reset },
|
||||
secondary_window: null,
|
||||
},
|
||||
additional_rate_limits:
|
||||
shape === "Spark"
|
||||
? [
|
||||
{
|
||||
limit_name: "GPT-5.3-Codex-Spark",
|
||||
metered_feature: "codex_bengalfox",
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 604800,
|
||||
reset_at: reset,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await usageItem(page).click();
|
||||
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
||||
await expectCodexReportedWindows(page, shape);
|
||||
await qaScreenshot(page, `codex-${shape}-pins`);
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const [size, viewport] of Object.entries({
|
||||
desktop: { width: 1440, height: 900 },
|
||||
compact: { width: 390, height: 844 },
|
||||
})) {
|
||||
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript((value) => {
|
||||
const key = "@paseo:app-settings";
|
||||
const current = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...current, theme: value }));
|
||||
}, theme);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize(viewport);
|
||||
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("../..");
|
||||
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 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 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 refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSettingsState(
|
||||
page: Page,
|
||||
testInfo: import("@playwright/test").TestInfo,
|
||||
name: string,
|
||||
) {
|
||||
await qaScreenshot(page, name);
|
||||
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
|
||||
}
|
||||
|
||||
@@ -3,14 +3,24 @@ import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installUsageReportsFixture,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
function forcedRefreshCount(usage: UsageReportsFixture): number {
|
||||
return usage.listRequests().filter((request) => request.forceRefresh).length;
|
||||
}
|
||||
|
||||
function report(input: {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
report: Partial<UsageReportEntry["report"]>;
|
||||
report:
|
||||
| Partial<Extract<UsageReportEntry["report"], { status: "available" }>>
|
||||
| Exclude<UsageReportEntry["report"], { status: "available" }>;
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:account`,
|
||||
@@ -19,11 +29,14 @@ function report(input: {
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
icon: ICON,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
report:
|
||||
input.report.status === "error" || input.report.status === "unavailable"
|
||||
? input.report
|
||||
: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +82,6 @@ test.describe("usage settings", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
@@ -83,7 +95,12 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
await card.getByTestId("usage-display-remaining").click();
|
||||
// Percentages now live on the Usage screen, and still apply to the host section.
|
||||
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
const hostUsageUrl = page.url();
|
||||
await page.goto("/usage");
|
||||
await showUsageAs(page, "remaining");
|
||||
await page.goto(hostUsageUrl);
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
await expect(card.getByText("93% left")).toBeVisible();
|
||||
});
|
||||
@@ -92,20 +109,14 @@ test.describe("usage settings", () => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
|
||||
// The sidebar summary and the section each load reports; only Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
(request) => [
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(23) },
|
||||
}),
|
||||
],
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(64) },
|
||||
report: { windows: windows(request.forceRefresh ? 64 : 23) },
|
||||
}),
|
||||
],
|
||||
],
|
||||
@@ -114,13 +125,12 @@ test.describe("usage settings", () => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usage.waitForListRequests(2);
|
||||
|
||||
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
|
||||
@@ -4,21 +4,30 @@ import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectNoPinnedUsage,
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
installTallUsageSource,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -33,7 +42,7 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k
|
||||
await page.waitForTimeout(600);
|
||||
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `phase4-${name}.png`);
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (area.kind === "element") {
|
||||
await area.locator.screenshot({ path: file });
|
||||
return;
|
||||
@@ -42,17 +51,38 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k
|
||||
await page.screenshot({ path: file, clip });
|
||||
}
|
||||
|
||||
/** The sidebar footer, from Add project down to the icon row, with some margin. */
|
||||
/** Includes summary rows above the fixed icon row. */
|
||||
async function footerClip(page: Page) {
|
||||
const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!;
|
||||
const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!;
|
||||
const margin = 16;
|
||||
const x = Math.max(0, top.x - margin);
|
||||
const y = Math.max(0, top.y - margin);
|
||||
return { x, y, width: 300, height: bottom.y + bottom.height + margin - y };
|
||||
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await usageItem(page).click();
|
||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
const bounds = (await content.boundingBox())!;
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -63,26 +93,51 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => {
|
||||
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" });
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await openSidebarNavSettings(page);
|
||||
await qaScreenshot(page, "desktop-settings-sidebar-footer");
|
||||
await page.setViewportSize(COMPACT);
|
||||
// Phones start with the Usage item off; turning it on stores the choice.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await qaScreenshot(page, "compact-settings-sidebar-footer");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "compact-footer-defaults");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await qaScreenshot(page, "default-pins-card");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked();
|
||||
});
|
||||
|
||||
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
// One host: the selector still names it.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectPinnedUsage(page, ["31%", "12%"]);
|
||||
// One host: no host filter, as on History.
|
||||
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked();
|
||||
await qaScreenshot(page, "default-pins-after-one-toggle");
|
||||
await togglePin(screen, "Codex", "Session");
|
||||
await expectPinnedUsage(page, ["31% 5h", "12% wk"]);
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
@@ -95,7 +150,8 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
|
||||
).toBeVisible();
|
||||
@@ -117,7 +173,7 @@ test.describe("Usage item", () => {
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
@@ -129,14 +185,26 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the Usage screen's controls, host selector included.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
// The sheet carries the same inline Settings row and title-row Refresh all.
|
||||
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await openUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "compact-settings-expanded");
|
||||
await sheet.getByTestId("usage-options-toggle").click();
|
||||
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())!;
|
||||
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
|
||||
// Settings adds a row; the sheet still 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");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
@@ -150,10 +218,14 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "usage-settings-expanded");
|
||||
await page.getByTestId("usage-options-toggle").click();
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
@@ -167,16 +239,18 @@ test.describe("Usage item", () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => {
|
||||
await test.step("unpinning everything stays empty after a reload", async () => {
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoPinnedUsage(page);
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
||||
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
@@ -190,3 +264,86 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("without summary data the footer drops the Usage item and keeps the Usage icon", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: new Date().toISOString(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectNoUsageItem(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
test("the Usage Settings show and hide the sidebar Usage item", async ({ page }) => {
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
|
||||
await openUsageOptions(page);
|
||||
const toggle = page.getByTestId("usage-show-in-sidebar").getByRole("switch");
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await toggle.click();
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("released hosts supply source logos through the client conversion", async ({ page }) => {
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
providerUsageListOnly: true,
|
||||
});
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
// Source logos are decorative SVGs with no accessible role. Their path data distinguishes
|
||||
// the source artwork from the fallback gauge.
|
||||
const claudePath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
|
||||
const codexPath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!;
|
||||
const summary = usageItem(page).getByTestId("sidebar-usage-source");
|
||||
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude").locator("svg path").first(),
|
||||
).toHaveAttribute("d", claudePath);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-screen");
|
||||
});
|
||||
|
||||
@@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
Keep generated screenshots, recordings, and run logs outside the repository.
|
||||
|
||||
## Stacked adaptive sheets
|
||||
|
||||
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
|
||||
confirmation. It first types without tapping the input to verify autofocus, then cancels the
|
||||
confirmation, closes and reopens the underlying sheet, confirms again,
|
||||
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
|
||||
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
|
||||
bottom sheet and centered dialog are exercised.
|
||||
|
||||
These integration journeys require the host-confirmation flow in the test app. Include both the
|
||||
sheet fix and that flow in the QA checkout when verifying them; the production sheet component
|
||||
has no dependency on the host-confirmation feature.
|
||||
|
||||
Use a fresh English app with notification permission granted and first-use keyboard prompts
|
||||
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
|
||||
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
|
||||
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
|
||||
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
|
||||
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
|
||||
daemon accepts relay connections with no credential.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
|
||||
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
|
||||
--udid "$SHEET_QA_IOS_UDID" \
|
||||
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
|
||||
--env OUT="$SHEET_QA_OUTPUT"
|
||||
```
|
||||
|
||||
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
|
||||
first. Use a second loopback spelling
|
||||
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
|
||||
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
|
||||
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
|
||||
|
||||
Android uses the same journeys: copy each script outside the repository, change its context's
|
||||
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
|
||||
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
|
||||
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
|
||||
default mobile suite, alongside the existing modal-sheet scripts.
|
||||
|
||||
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
|
||||
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
|
||||
URI one character at a time passed with both implementations; faster typing remains unverified.
|
||||
On iPad, replacing the field a second time appends text. For this Settings journey, use the
|
||||
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
|
||||
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
|
||||
runs end to end without these adjustments.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
|
||||
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"welcome-paste-pairing-link\"" 20000
|
||||
screenshot "${OUT}/01-welcome.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
# Type without tapping the input: opening the sheet must retain its autofocus.
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
type "relay://"
|
||||
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
|
||||
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/02-pairing-input.png"
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/03-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
|
||||
# Close and reopen the underlying sheet, then edit it and submit again.
|
||||
press "id=\"pair-link-cancel\""
|
||||
wait stable
|
||||
wait "id=\"welcome-paste-pairing-link\"" 10000
|
||||
screenshot "${OUT}/05-underlying-sheet-closes.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/06-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-password-input\"" 20000
|
||||
screenshot "${OUT}/07-password-after-connect.png"
|
||||
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait text "Open a folder on your machine" 30000
|
||||
screenshot "${OUT}/08-connected.png"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Settings with Add host visible: no saved hosts, or the host picker already open.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"settings-add-host\"" 20000
|
||||
screenshot "${OUT}/09-settings.png"
|
||||
press "id=\"settings-add-host\""
|
||||
wait stable
|
||||
wait "id=\"add-host-method-direct\"" 10000
|
||||
screenshot "${OUT}/10-add-host-methods.png"
|
||||
press "id=\"add-host-method-direct\""
|
||||
wait stable
|
||||
press "id=\"direct-host-advanced-toggle\""
|
||||
wait stable
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/11-add-host-relay-input.png"
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"direct-host-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
|
||||
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"direct-password-input\"" 20000
|
||||
screenshot "${OUT}/15-add-host-password.png"
|
||||
fill "id=\"direct-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
open "${APP_ID}" "paseo://settings"
|
||||
wait "label=\"Connections\"" 10000
|
||||
press "label=\"Connections\""
|
||||
wait stable
|
||||
wait "id=\"host-page-connections-card\"" 30000
|
||||
screenshot "${OUT}/16-settings-connected.png"
|
||||
@@ -47,7 +47,7 @@ async function openAddProjectFlowSurface(
|
||||
page: Page,
|
||||
expectedPage: "host" | "method",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await page.locator('[data-testid="sidebar-add-project"]:visible').click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectAddProjectPage(page, expectedPage);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type AgentSkillsClient = Pick<
|
||||
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
|
||||
>;
|
||||
|
||||
export type SkillTargetName = "agents" | "claude" | "codex";
|
||||
export type SkillTargetName = "agents" | "claude";
|
||||
|
||||
export interface AgentSkillsSandbox {
|
||||
available: string[];
|
||||
@@ -30,6 +30,7 @@ export interface AgentSkillsSandbox {
|
||||
daemon: IsolatedHostDaemon;
|
||||
home: string;
|
||||
targets: Record<SkillTargetName, string>;
|
||||
codexSkillsDir: string;
|
||||
blockAgentsDirectory(): Promise<void>;
|
||||
unblockAgentsDirectory(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -63,8 +64,8 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||
const targets = {
|
||||
agents: path.join(home, ".agents", "skills"),
|
||||
claude: path.join(home, ".claude", "skills"),
|
||||
codex: path.join(home, ".codex", "skills"),
|
||||
};
|
||||
const codexSkillsDir = path.join(home, ".codex", "skills");
|
||||
|
||||
return {
|
||||
available,
|
||||
@@ -72,6 +73,7 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||
daemon,
|
||||
home,
|
||||
targets,
|
||||
codexSkillsDir,
|
||||
blockAgentsDirectory: async () => {
|
||||
await mkdir(path.dirname(targets.agents), { recursive: true });
|
||||
await writeFile(targets.agents, "not a directory", "utf8");
|
||||
@@ -196,6 +198,7 @@ export async function expectInstalledSkills(
|
||||
for (const directory of Object.values(sandbox.targets)) {
|
||||
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
|
||||
}
|
||||
await expect.poll(() => readInstalledSkills(sandbox.codexSkillsDir)).toEqual([]);
|
||||
}
|
||||
|
||||
export async function expectSavedSelection(
|
||||
|
||||
@@ -35,18 +35,14 @@ export default function contribute(client) {
|
||||
|
||||
async function openExplorerMenu(page: Page) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
// The rail's empty background has no accessible role; tab menus are separate.
|
||||
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
|
||||
button: "right",
|
||||
position: { x: 20, y: 2 },
|
||||
});
|
||||
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toHaveCSS("opacity", "1");
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function toggleExplorerView(page: Page, name: string) {
|
||||
async function launchExplorerView(page: Page, name: string | RegExp) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name, exact: true }).click();
|
||||
await expect(menu).not.toBeVisible();
|
||||
@@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins(
|
||||
|
||||
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
|
||||
const launcher = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openWorkspacePanelFromExplorerMenu(
|
||||
@@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu(
|
||||
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"New tab",
|
||||
"Changes",
|
||||
"Files",
|
||||
"Other review",
|
||||
"Other review summary",
|
||||
"Review",
|
||||
"Review summary",
|
||||
]);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Other review summary", exact: true }),
|
||||
).toBeVisible();
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectIndependentExplorerPanelToggles(
|
||||
export async function expectIndependentExplorerPanelTabs(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
) {
|
||||
await toggleExplorerView(page, "Other review");
|
||||
await launchExplorerView(page, "Other review");
|
||||
await expect(
|
||||
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await toggleExplorerView(page, "Review summary");
|
||||
await launchExplorerView(page, "Review summary");
|
||||
const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
|
||||
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
|
||||
await tabs
|
||||
.getByRole("button", { name: "Review", exact: true })
|
||||
.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0);
|
||||
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
|
||||
const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true });
|
||||
await expect(files).toHaveCount(1);
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
const updated = await openExplorerMenu(page);
|
||||
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Other review", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(files).toHaveCount(0);
|
||||
await launchExplorerView(page, /^Files/);
|
||||
await expect(files).toHaveCount(1);
|
||||
const restoredMenu = await openExplorerMenu(page);
|
||||
await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("explorer-sidebar-tab-rail")
|
||||
.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectExplorerPanelWithFocusedAgent(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
export interface LinkedHost {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
daemonPublicKeyB64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pairing link that opens on the settings page. `/` redirects to
|
||||
* `/open-project` on its own, so starting in settings leaves the pairing link
|
||||
* as the only thing that can move the app to `/open-project`.
|
||||
*/
|
||||
export function buildPairingLink(host: LinkedHost): string {
|
||||
const offer = {
|
||||
v: 2,
|
||||
serverId: host.serverId,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
relay: { endpoint: host.relayEndpoint, useTls: false },
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
|
||||
return `/settings/general#offer=${encoded}`;
|
||||
}
|
||||
|
||||
/** Loads the app from a pairing link, the way opening the link in a browser does. */
|
||||
export async function openPairingLink(page: Page, link: string): Promise<void> {
|
||||
// Leave the link's page first so the link always loads the app from scratch.
|
||||
await page.goto("/");
|
||||
await page.goto(link);
|
||||
}
|
||||
|
||||
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
|
||||
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const connectionId = `relay:${host.relayEndpoint}`;
|
||||
const savedHost = {
|
||||
serverId: host.serverId,
|
||||
label: host.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
type: "relay",
|
||||
relayEndpoint: host.relayEndpoint,
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: connectionId,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
|
||||
key: EXTRA_HOSTS_KEY,
|
||||
hosts: [savedHost],
|
||||
});
|
||||
}
|
||||
|
||||
export function hostConfirmation(page: Page): Locator {
|
||||
return page.getByTestId("host-confirmation");
|
||||
}
|
||||
|
||||
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
|
||||
const sheet = hostConfirmation(page);
|
||||
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
|
||||
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
|
||||
}
|
||||
|
||||
export async function cancelHostConfirmation(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function connectToConfirmedHost(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Checks the host picker on the settings page the app is already showing. */
|
||||
export async function expectHostInHostPicker(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
expected: "listed" | "not listed",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
|
||||
expected === "listed" ? 1 : 0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
|
||||
|
||||
@@ -18,16 +19,24 @@ export class ImportSessionFlow {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
async revealMobileEntryPoint() {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
const button = this.page.getByTestId("sidebar-import-session");
|
||||
/** The new workspace screen's Import session button, reached from the sidebar. */
|
||||
async revealNewWorkspaceEntryPoint() {
|
||||
const compact = (this.page.viewportSize()?.width ?? 0) < 768;
|
||||
if (compact) {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
}
|
||||
await openGlobalNewWorkspaceComposer(this.page);
|
||||
const button = this.page.getByTestId("new-workspace-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
async openGlobally() {
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await this.page.getByTestId("sidebar-import-session").click();
|
||||
await this.revealNewWorkspaceEntryPoint();
|
||||
await this.openFromNewWorkspace();
|
||||
}
|
||||
async openFromNewWorkspace() {
|
||||
await this.page.getByTestId("new-workspace-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -108,8 +117,12 @@ export class ImportSessionFlow {
|
||||
});
|
||||
}
|
||||
async expectTranscript(userText: string, assistantText: string) {
|
||||
await expect(this.page.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
|
||||
await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
userText,
|
||||
);
|
||||
await expect(
|
||||
this.page.getByTestId("assistant-message").filter({ visible: true }),
|
||||
).toContainText(assistantText);
|
||||
}
|
||||
async showAll() {
|
||||
await this.page.getByTestId("import-session-show-all").click();
|
||||
@@ -120,7 +133,9 @@ export class ImportSessionFlow {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
userText,
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
|
||||
@@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer(
|
||||
}
|
||||
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").click();
|
||||
// The shell keeps a hidden copy of the other layout's sidebar mounted.
|
||||
await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
|
||||
@@ -162,7 +163,6 @@ async function expectVerticalOrder<Key extends string>(
|
||||
|
||||
/** Persisted footer row key -> the testID the app shell renders that row with. */
|
||||
function shellFooterTestID(key: string): string {
|
||||
// With nothing pinned the Usage item is the plain Usage row.
|
||||
if (key === "usage") return "sidebar-usage";
|
||||
const [, pluginId, itemId] = key.split(":");
|
||||
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
|
||||
@@ -218,24 +218,69 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise<v
|
||||
}
|
||||
|
||||
const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-import-session",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
|
||||
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
|
||||
const FOOTER_GEOMETRY_TOLERANCE = 0.01;
|
||||
|
||||
/**
|
||||
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
|
||||
* Settings together at the end.
|
||||
*/
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
);
|
||||
const [first, ...rest] = boxes.map((box) => box!);
|
||||
let previous = first;
|
||||
for (const box of rest) {
|
||||
expect(Math.abs(box.y - first.y)).toBeLessThan(2);
|
||||
expect(box.x).toBeGreaterThan(previous.x);
|
||||
previous = box;
|
||||
const boxes = (
|
||||
await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
)
|
||||
).map((box) => box!);
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
expect(Math.abs(box.width - first!.width)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
|
||||
}
|
||||
const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width));
|
||||
for (const index of [0, 1, 3]) {
|
||||
expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
|
||||
}
|
||||
expect(gaps[2]).toBeGreaterThan(first!.width);
|
||||
}
|
||||
|
||||
export async function expectFooterSeparator(page: Page, shown: boolean): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"1px",
|
||||
);
|
||||
await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
);
|
||||
const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible');
|
||||
await expect(separator).toHaveCount(shown ? 1 : 0);
|
||||
expect(
|
||||
await separator.evaluateAll((elements) =>
|
||||
elements.map((element) => getComputedStyle(element).borderBottomWidth),
|
||||
),
|
||||
).toEqual(shown ? ["1px"] : []);
|
||||
}
|
||||
|
||||
export async function hoverFooterAddProject(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
|
||||
const tooltip = page.getByTestId("sidebar-add-project-tooltip");
|
||||
await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible();
|
||||
await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function footerScreenshot(page: Page, name: string): Promise<void> {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ class StartupAssertions {
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
|
||||
|
||||
export const scrollCadences = [
|
||||
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
|
||||
{ name: "medium", delta: 480, intervalMs: 32, steps: 400 },
|
||||
{ name: "fast", delta: 1200, intervalMs: 16, steps: 180 },
|
||||
] as const;
|
||||
|
||||
type Cadence = (typeof scrollCadences)[number];
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
interface Recording {
|
||||
frames: ScrollFrame[];
|
||||
finishInput(): void;
|
||||
stop(): void;
|
||||
}
|
||||
interface TimelinePage {
|
||||
direction: string;
|
||||
count: number;
|
||||
hasOlder: boolean;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
|
||||
export async function withVariedTimeline(
|
||||
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
const server = createServer((request, response) => {
|
||||
const portrait = request.url?.includes("portrait");
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timers.delete(timer);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "no-store",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
response.end(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${portrait ? 320 : 960}" height="${portrait ? 640 : 240}"><rect width="100%" height="100%" fill="${portrait ? "#936" : "#369"}"/><text x="20" y="50" fill="white" font-size="24">Delayed ${portrait ? "portrait" : "landscape"}</text></svg>`,
|
||||
);
|
||||
},
|
||||
portrait ? 900 : 350,
|
||||
);
|
||||
timers.add(timer);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Image server did not listen");
|
||||
const imageUrl = `http://127.0.0.1:${address.port}`;
|
||||
let agent: MockAgentWorkspace | undefined;
|
||||
try {
|
||||
agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-scroll-smoothness-",
|
||||
title: "Varied timeline scroll reproduction",
|
||||
featureValues: {
|
||||
mockAssistantResponses: Array.from({ length: 70 }, (_, turn) =>
|
||||
[
|
||||
`## Review of turn ${turn}`,
|
||||
"A short response followed by blocks with different heights. Read the comparison, inspect the screenshot, and continue to the next turn.",
|
||||
"| Component | Observation | Next step |\n| --- | --- | --- |\n| Pagination | Older turns arrive together | Keep the visible text anchored |\n| Images | Intrinsic sizes arrive later | Preserve the reading position |\n| Virtualization | Estimated heights become measured heights | Keep wheel input moving upward |",
|
||||
``,
|
||||
"```typescript\nconst viewport = { top: 0, height: 800 };\nfor (const row of history) {\n inspect(row, viewport);\n}\n```",
|
||||
"> Keep reading while content above the viewport changes.",
|
||||
"- Short item\n- A longer item that wraps when the available width is smaller and adds another height variation\n- Final item",
|
||||
``,
|
||||
"Review complete. The next prompt starts a separate turn.",
|
||||
].join("\n\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
for (let turn = 0; turn < 70; turn += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`Review turn ${turn}. ${"Compare the image and table with the earlier result. ".repeat(1 + (turn % 4))}`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await run(
|
||||
agent,
|
||||
`Review turn 69. ${"Compare the image and table with the earlier result. ".repeat(2)}`,
|
||||
);
|
||||
} finally {
|
||||
await agent?.cleanup();
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
export function observeTimelinePages(page: Page, agentId: string): TimelinePage[] {
|
||||
const pages: TimelinePage[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const envelope = JSON.parse(String(payload));
|
||||
const message = envelope.message;
|
||||
if (message?.type !== "fetch_agent_timeline_response" || message.payload.agentId !== agentId)
|
||||
return;
|
||||
pages.push({
|
||||
direction: message.payload.direction,
|
||||
count: message.payload.entries.length,
|
||||
hasOlder: message.payload.hasOlder,
|
||||
at: Date.now(),
|
||||
});
|
||||
});
|
||||
});
|
||||
return pages;
|
||||
}
|
||||
|
||||
export async function openOnlyTimelineTail(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
newestPrompt: string,
|
||||
pages: TimelinePage[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(() => Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true));
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, newestPrompt.trim());
|
||||
await expect
|
||||
.poll(() => pages.filter((entry) => entry.direction === "tail").length)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(pages[0]).toMatchObject({ direction: "tail", hasOlder: true });
|
||||
expect(pages[0].count).toBe(40);
|
||||
expect(pages.filter((entry) => entry.direction === "tail")).toHaveLength(1);
|
||||
expect(
|
||||
pages.filter((entry) => entry.direction === "after").every((entry) => entry.count === 0),
|
||||
).toBe(true);
|
||||
await expect(page.getByText(/^Review turn 0\./)).toHaveCount(0);
|
||||
// Start each cadence after the tail's initial image loads and bottom anchoring.
|
||||
await page.waitForTimeout(1500);
|
||||
expect(pages.filter((entry) => entry.direction === "before")).toHaveLength(0);
|
||||
}
|
||||
|
||||
export async function recordUpwardTraversal(
|
||||
page: Page,
|
||||
cadence: Cadence,
|
||||
testInfo: TestInfo,
|
||||
): Promise<ScrollFrame[]> {
|
||||
const stopTrace = await traceScrollIfRequested(page, testInfo, cadence);
|
||||
const timeline = page.getByTestId("agent-chat-scroll");
|
||||
await timeline.hover();
|
||||
await timeline.evaluate((viewport) => {
|
||||
const scroll = viewport as HTMLElement;
|
||||
const frames: ScrollFrame[] = [];
|
||||
let frame = 0;
|
||||
let wheelTotal = 0;
|
||||
let lastWheelAt = -Infinity;
|
||||
let inputFinishedAt: number | null = null;
|
||||
let imageLoads = 0;
|
||||
let mounted = scroll.querySelectorAll("[data-history-row-id]").length;
|
||||
let unmounted = 0;
|
||||
const collectRows = (node: Node, rows: Set<Element>) => {
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node.matches("[data-history-row-id]")) rows.add(node);
|
||||
for (const row of node.querySelectorAll("[data-history-row-id]")) rows.add(row);
|
||||
};
|
||||
const recordMutations = (records: MutationRecord[]) => {
|
||||
// A newly inserted container and its children can both appear in one
|
||||
// delivery. Count each DOM row once, including rows removed before paint.
|
||||
const added = new Set<Element>();
|
||||
const removed = new Set<Element>();
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) collectRows(node, added);
|
||||
for (const node of record.removedNodes) collectRows(node, removed);
|
||||
}
|
||||
mounted += added.size;
|
||||
unmounted += removed.size;
|
||||
};
|
||||
const mutations = new MutationObserver(recordMutations);
|
||||
mutations.observe(scroll, { childList: true, subtree: true });
|
||||
const wheel = (event: WheelEvent) => {
|
||||
wheelTotal += -event.deltaY;
|
||||
lastWheelAt = performance.now();
|
||||
};
|
||||
const imageLoad = (event: Event) => {
|
||||
if (event.target instanceof HTMLImageElement) imageLoads += 1;
|
||||
};
|
||||
scroll.addEventListener("wheel", wheel, { passive: true });
|
||||
scroll.addEventListener("load", imageLoad, true);
|
||||
const observed = new Set<Element>();
|
||||
const capture = (replaceLast: boolean) => {
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
const elements = Array.from(scroll.querySelectorAll<HTMLElement>("[data-history-row-id]"));
|
||||
const current = new Set<Element>([scroll, ...elements]);
|
||||
if (scroll.firstElementChild) current.add(scroll.firstElementChild);
|
||||
for (const element of observed) {
|
||||
if (!current.has(element)) {
|
||||
resizeObserver.unobserve(element);
|
||||
observed.delete(element);
|
||||
}
|
||||
}
|
||||
for (const element of current) {
|
||||
if (!observed.has(element)) {
|
||||
resizeObserver.observe(element);
|
||||
observed.add(element);
|
||||
}
|
||||
}
|
||||
const rows = elements.map((row) => {
|
||||
const box = row.getBoundingClientRect();
|
||||
return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height };
|
||||
});
|
||||
// Include rows mounted and removed in the same commit, before sampling.
|
||||
recordMutations(mutations.takeRecords());
|
||||
// Preserve the row crossing the reading line. Rows below an expanding
|
||||
// visible image legitimately move even when the reading position is stable.
|
||||
const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8);
|
||||
const snapshot: ScrollFrame = {
|
||||
at: performance.now(),
|
||||
scrollTop: scroll.scrollTop,
|
||||
scrollHeight: scroll.scrollHeight,
|
||||
viewportHeight: rect.height,
|
||||
rows,
|
||||
anchor: anchor?.id ?? null,
|
||||
virtualized: scroll.id.includes("virtualized"),
|
||||
loading: !!scroll.querySelector('[data-testid="load-older-history-spinner"]'),
|
||||
wheelTotal,
|
||||
lastWheelAt,
|
||||
inputFinishedAt,
|
||||
imageLoads,
|
||||
mounted,
|
||||
unmounted,
|
||||
};
|
||||
if (replaceLast && frames.length > 0) frames[frames.length - 1] = snapshot;
|
||||
else frames.push(snapshot);
|
||||
};
|
||||
// rAF precedes ResizeObserver. Refresh that frame after resize delivery so
|
||||
// it represents the final layout, not geometry repaired before the paint.
|
||||
const resizeObserver = new ResizeObserver(() => capture(true));
|
||||
const sample = () => {
|
||||
capture(false);
|
||||
frame = requestAnimationFrame(sample);
|
||||
};
|
||||
const recording: Recording = {
|
||||
frames,
|
||||
finishInput() {
|
||||
inputFinishedAt = performance.now();
|
||||
},
|
||||
stop() {
|
||||
cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
mutations.disconnect();
|
||||
scroll.removeEventListener("wheel", wheel);
|
||||
scroll.removeEventListener("load", imageLoad, true);
|
||||
},
|
||||
};
|
||||
Reflect.set(window, "__timelineScrollRecording", recording);
|
||||
const reset = Reflect.get(window, "__PASEO_RESET_RENDER_PROFILE__");
|
||||
if (typeof reset === "function") reset();
|
||||
sample();
|
||||
});
|
||||
// No settling between inputs: exercise wheel input overlapping prepend and measurement.
|
||||
for (let step = 0; step < cadence.steps; step += 1) {
|
||||
await page.mouse.wheel(0, -cadence.delta);
|
||||
await page.waitForTimeout(cadence.intervalMs);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
(Reflect.get(window, "__timelineScrollRecording") as Recording).finishInput();
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
await stopTrace();
|
||||
return page.evaluate(() => {
|
||||
const recording = Reflect.get(window, "__timelineScrollRecording") as Recording;
|
||||
recording.stop();
|
||||
return recording.frames;
|
||||
});
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
|
||||
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
|
||||
const readingLine = 8 - availableScroll;
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// Wheel events can precede their scroll update, or include input already
|
||||
// applied in earlier frames. Locate the reader with this frame's scroll,
|
||||
// bounded by recent input. Growth below it can move the old anchor without
|
||||
// moving the reading line.
|
||||
const readerFollowsInput =
|
||||
intendedRow &&
|
||||
currentRows.has(intendedRow.id) &&
|
||||
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
|
||||
const enteredRowGrowth = readerFollowsInput
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + Math.max(0, height - row.height);
|
||||
}, 0)
|
||||
: 0;
|
||||
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportScrollJumps(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
frames: ScrollFrame[],
|
||||
pages: TimelinePage[],
|
||||
): Promise<void> {
|
||||
const jumps = findScrollJumps(frames);
|
||||
const react = await page.evaluate(() => Reflect.get(window, "__PASEO_RENDER_PROFILE__") ?? []);
|
||||
const gaps = frames
|
||||
.slice(1)
|
||||
.map((frame, index) => frame.at - frames[index].at)
|
||||
.sort((a, b) => a - b);
|
||||
const summary = {
|
||||
browserTimeOrigin: await page.evaluate(() => performance.timeOrigin),
|
||||
frames: frames.length,
|
||||
pages,
|
||||
jumpCount: jumps.length,
|
||||
worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)),
|
||||
frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)],
|
||||
frameGapMaxMs: gaps.at(-1),
|
||||
imageLoads: frames[frames.length - 1].imageLoads,
|
||||
mounted: frames[frames.length - 1].mounted,
|
||||
unmounted: frames[frames.length - 1].unmounted,
|
||||
jumps,
|
||||
};
|
||||
await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2));
|
||||
await writeFile(testInfo.outputPath("scroll-frames.json"), JSON.stringify(frames));
|
||||
await writeFile(testInfo.outputPath("scroll-react-profile.json"), JSON.stringify(react));
|
||||
await testInfo.attach("scroll-summary", {
|
||||
body: JSON.stringify(summary, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("scroll-frames", {
|
||||
body: JSON.stringify(frames),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("scroll-react-profile", {
|
||||
body: JSON.stringify(react),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await attachTimelineScreenshot(page, testInfo, "scroll-end");
|
||||
console.log(
|
||||
`[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`,
|
||||
);
|
||||
expect(
|
||||
pages.filter((entry) => entry.direction === "before").length,
|
||||
"must exercise multiple older pages",
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
expect(
|
||||
frames.some((frame) => frame.virtualized),
|
||||
"must exercise virtualization",
|
||||
).toBe(true);
|
||||
expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0);
|
||||
expect(
|
||||
summary.mounted - summary.unmounted,
|
||||
"row counters must balance against the final DOM",
|
||||
).toBe(frames[frames.length - 1].rows.length);
|
||||
const uniqueRows = new Set(frames.flatMap((frame) => frame.rows.map((row) => row.id))).size;
|
||||
expect(
|
||||
summary.mounted,
|
||||
"prepending must not mount a discarded range before correcting the viewport",
|
||||
).toBeLessThanOrEqual(uniqueRows * 1.25);
|
||||
expect(
|
||||
jumps,
|
||||
"visible text must not reverse direction while scrolling upward or move after input stops",
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
async function traceScrollIfRequested(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
cadence: Cadence,
|
||||
): Promise<() => Promise<void>> {
|
||||
if (process.env.PASEO_TIMELINE_SCROLL_TRACE !== cadence.name) return async () => {};
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Tracing.start", {
|
||||
categories:
|
||||
"devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-v8.cpu_profiler",
|
||||
transferMode: "ReturnAsStream",
|
||||
});
|
||||
return async () => {
|
||||
const completed = new Promise<string>((resolve) => {
|
||||
cdp.once("Tracing.tracingComplete", ({ stream }) => resolve(stream!));
|
||||
});
|
||||
await cdp.send("Tracing.end");
|
||||
const stream = await completed;
|
||||
const chunks: string[] = [];
|
||||
for (;;) {
|
||||
const chunk = await cdp.send("IO.read", { handle: stream });
|
||||
chunks.push(chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString() : chunk.data);
|
||||
if (chunk.eof) break;
|
||||
}
|
||||
await cdp.send("IO.close", { handle: stream });
|
||||
await cdp.detach();
|
||||
const path = testInfo.outputPath("scroll-chromium-trace.json");
|
||||
await writeFile(path, chunks.join(""));
|
||||
await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" });
|
||||
};
|
||||
}
|
||||
|
||||
/** Hold real image bytes until the reader has reached its placeholder. */
|
||||
export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
let released = false;
|
||||
const pending = new Set<() => void>();
|
||||
const server = createServer((_request, response) => {
|
||||
const send = () => {
|
||||
response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" });
|
||||
response.end(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#369"/></svg>',
|
||||
);
|
||||
};
|
||||
if (released) send();
|
||||
else pending.add(send);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Image server did not listen");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "reserved-timeline-image-",
|
||||
title: "Reserved image geometry",
|
||||
featureValues: {
|
||||
mockAssistantResponses: [
|
||||
`Before the image.\n\n\n\nText below the image.`,
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the delayed image");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, "Show the delayed image");
|
||||
const image = page.getByRole("img", { name: "Reserved image" }).first();
|
||||
await expect(image).toBeVisible();
|
||||
const before = await image.boundingBox();
|
||||
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
|
||||
released = true;
|
||||
for (const send of pending) send();
|
||||
await expect
|
||||
.poll(() =>
|
||||
image.evaluate((element) => {
|
||||
const img =
|
||||
element instanceof HTMLImageElement ? element : element.querySelector("img");
|
||||
return img?.naturalWidth;
|
||||
}),
|
||||
)
|
||||
.toBe(240);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await image.boundingBox();
|
||||
return box ? Math.round((box.width / box.height) * 100) : 0;
|
||||
})
|
||||
.toBe(150);
|
||||
const after = await image.boundingBox();
|
||||
await attachTimelineScreenshot(page, testInfo, "image-loaded");
|
||||
expect(before?.height, "reserve the final image height before the HTTP response").toBe(
|
||||
after?.height,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
async function attachTimelineScreenshot(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.getByTestId("agent-chat-scroll").screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UsageReport } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expectPinnedUsage, pinRow, usageItem } from "./usage-sidebar-item";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
/** A real store-backed plugin on the worker's isolated daemon. */
|
||||
export async function installLoginUsage(initialReport: UsageReport) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const store = path.join(directory, "report.json");
|
||||
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("login-journey");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await setReport(initialReport);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "login-journey", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {z} from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
|
||||
discover: async () => [{key: "account", input: {}}],
|
||||
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { setReport, cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUsage(page: Page) {
|
||||
await page.goto("/usage");
|
||||
}
|
||||
|
||||
export async function refreshLoginUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function hoverUsageWindow(page: Page, window: string) {
|
||||
await page
|
||||
.getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) })
|
||||
.getByTestId(/usage-pin-glyph-/)
|
||||
.hover();
|
||||
}
|
||||
|
||||
/** Exercises the built-in Codex adapter through the real plugin loader and daemon transport. */
|
||||
export async function installCodexWindowUsage(payload: unknown) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "usage-codex-windows-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("codex-window-qa");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
const source = path.resolve(__dirname, "../../../../../plugins/codex-usage-source");
|
||||
await cp(path.join(source, "server"), path.join(directory, "server"), { recursive: true });
|
||||
await cp(path.join(source, "shared"), path.join(directory, "shared"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "auth.json"),
|
||||
JSON.stringify({ tokens: { access_token: "fixture-codex" } }),
|
||||
);
|
||||
await writeFile(path.join(directory, "payload.json"), JSON.stringify(payload));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "codex-window-qa", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {fetchUsage, discover} from "./server/usage.js";
|
||||
import {inputSchema} from "./shared/input.js";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema,
|
||||
discover: () => discover({home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}),
|
||||
fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))),
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCodexReportedWindows(page: Page, shape: "seven-day-only" | "Spark") {
|
||||
await expectPinnedUsage(page, ["11% wk"]);
|
||||
await expect(pinRow(page.locator("body"), "Codex", "Weekly")).toBeChecked();
|
||||
await expect(page.getByText("Session", { exact: true })).toHaveCount(0);
|
||||
if (shape === "Spark") {
|
||||
const fiveHour = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · 5-hour");
|
||||
const weekly = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · Weekly");
|
||||
await expect(fiveHour).not.toBeChecked();
|
||||
await expect(weekly).not.toBeChecked();
|
||||
await fiveHour.click();
|
||||
await expect(fiveHour).toBeChecked();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(
|
||||
/Codex Weekly 11% used, Codex GPT-5.3-Codex-Spark · 5-hour 0% used/,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||
|
||||
export interface UsageListRequest {
|
||||
forceRefresh: boolean;
|
||||
reportIds?: string[];
|
||||
}
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
|
||||
listRequests(): UsageListRequest[];
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
/**
|
||||
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
|
||||
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
|
||||
* request; a function builds the response from the request when it arrives (e.g. a fresh
|
||||
* `fetchedAt`, or a different answer to a forced refresh).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
|
||||
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
|
||||
/** False simulates a host with no usage reporting capability. */
|
||||
usageSupported?: boolean;
|
||||
/** Released hosts expose provider.usage.list with no source icons. */
|
||||
providerUsageListOnly?: boolean;
|
||||
/** The host daemon's port; defaults to the E2E daemon. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
@@ -38,7 +48,11 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return envelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null {
|
||||
function withUsageSupportFeature(
|
||||
message: WebSocketMessage,
|
||||
enabled: boolean,
|
||||
providerUsageListOnly: boolean,
|
||||
): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; payload?: Record<string, unknown> };
|
||||
@@ -59,7 +73,11 @@ function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): s
|
||||
...envelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: { ...features, usageSources: enabled, providerUsageList: enabled },
|
||||
features: {
|
||||
...features,
|
||||
usageSources: enabled && !providerUsageListOnly,
|
||||
providerUsageList: enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -93,23 +111,30 @@ export async function installUsageReportsFixture(
|
||||
page: Page,
|
||||
options: UsageReportsFixtureOptions,
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const providerUsageListOnly = options.providerUsageListOnly ?? false;
|
||||
const requestType = providerUsageListOnly
|
||||
? "provider.usage.list.request"
|
||||
: "usage.list_reports.request";
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const route =
|
||||
options.port === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(`${options.port}`);
|
||||
await page.routeWebSocket(route, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = getSessionMessage(message);
|
||||
const requestId = request?.requestId;
|
||||
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
|
||||
listRequests.push({
|
||||
if (request?.type === requestType && typeof requestId === "string") {
|
||||
const listRequest: UsageListRequest = {
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
});
|
||||
};
|
||||
listRequests.push(listRequest);
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
const response = typeof scripted === "function" ? scripted() : scripted;
|
||||
const response = typeof scripted === "function" ? scripted(listRequest) : scripted;
|
||||
if ("error" in response) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -118,7 +143,7 @@ export async function installUsageReportsFixture(
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "usage.list_reports.request",
|
||||
requestType,
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
@@ -130,12 +155,28 @@ export async function installUsageReportsFixture(
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
|
||||
}),
|
||||
);
|
||||
const reply = providerUsageListOnly
|
||||
? {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
providers: reports.map((entry) => ({
|
||||
providerId: entry.sourceId,
|
||||
displayName: entry.sourceLabel,
|
||||
fetchedAt: entry.fetchedAt,
|
||||
status: entry.report.status,
|
||||
windows: entry.report.status === "available" ? entry.report.windows : [],
|
||||
balances: entry.report.status === "available" ? entry.report.balances : undefined,
|
||||
details: entry.report.status === "available" ? entry.report.details : undefined,
|
||||
planLabel:
|
||||
entry.report.status === "available" ? (entry.report.planLabel ?? null) : null,
|
||||
error: entry.report.status === "error" ? entry.report.error : null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { type: "usage.list_reports.response", payload: { requestId, reports } };
|
||||
ws.send(JSON.stringify({ type: "session", message: reply }));
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +185,9 @@ export async function installUsageReportsFixture(
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null;
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, usageSupported, providerUsageListOnly)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,58 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
|
||||
export async function installTallUsageSource() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("tall-usage");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "tall-usage",
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { z } from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({
|
||||
id: "tall-usage", label: "Scrolling account", input: z.object({}),
|
||||
discover: async () => [{key: "scrolling-account", input: {}}],
|
||||
fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i), label: "Window " + (i + 1), usedPct: 25,
|
||||
})) }),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
|
||||
|
||||
@@ -28,8 +79,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
status: "available",
|
||||
planLabel: "Max",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
|
||||
{
|
||||
id: "five_hour",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 31,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 54,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -44,8 +109,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 7,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 12,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -57,7 +136,7 @@ function visible(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
|
||||
/** The sidebar footer's Usage item: each summary window with data. */
|
||||
export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
@@ -71,15 +150,16 @@ export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
|
||||
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(windows).toHaveText(percents);
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
|
||||
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(pinned).toHaveText(windows);
|
||||
}
|
||||
|
||||
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
|
||||
export async function expectNoPinnedUsage(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
|
||||
await expect(usageItem(page)).toHaveText("Usage");
|
||||
/** Without a summary window with data the footer has no Usage item, only the Usage icon. */
|
||||
export async function expectNoUsageItem(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible();
|
||||
}
|
||||
|
||||
/** A window row, which is itself the pin toggle: "Claude", "Session". */
|
||||
@@ -95,8 +175,24 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
|
||||
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
|
||||
/** Expand the inline Settings row when its controls are folded. */
|
||||
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();
|
||||
await expect(visible(page, "usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
await openUsageOptions(page);
|
||||
await visible(page, `usage-display-${displayAs}`).click();
|
||||
await expect(visible(page, `usage-display-${displayAs}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-refresh-all").click();
|
||||
}
|
||||
|
||||
/** Opens the compact sidebar drawer. */
|
||||
|
||||
@@ -89,7 +89,7 @@ async function openExplorerView(
|
||||
): Promise<void> {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
const tab = explorer.getByTestId(view.tabTestId);
|
||||
await tab.click();
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
timeout: view.timeout ?? 30_000,
|
||||
});
|
||||
@@ -97,7 +97,7 @@ async function openExplorerView(
|
||||
|
||||
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||
tabTestId: "workspace-tab-changes_tree",
|
||||
contentTestId: "changes-tree-panel",
|
||||
});
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise<vo
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-files",
|
||||
tabTestId: "workspace-tab-files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { resolve } = require("metro-resolver");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const fdroidModuleOverrides = {
|
||||
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
|
||||
@@ -81,15 +79,6 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
|
||||
}
|
||||
|
||||
const origin = context.originModulePath;
|
||||
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
|
||||
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
|
||||
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return resolveWithCustomWebOverlay(context, tsModuleName, platform);
|
||||
}
|
||||
}
|
||||
|
||||
return resolveWithCustomWebOverlay(context, moduleName, platform);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -102,7 +102,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
describe("reading anchor", () => {
|
||||
const rows = [
|
||||
{ id: "above", top: 0, height: 500 },
|
||||
{ id: "reading", top: 500, height: 500 },
|
||||
{ id: "below", top: 1000, height: 500 },
|
||||
];
|
||||
|
||||
it("preserves wheel movement during an asynchronous prepend", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
2280,
|
||||
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2280);
|
||||
});
|
||||
|
||||
it("ignores growth below the reader and compensates growth above", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, { ...rows[2]!, height: 2000 }])).toBe(600);
|
||||
expect(
|
||||
anchor.reconcile(600, [
|
||||
{ ...rows[0]!, height: 1000 },
|
||||
{ ...rows[1]!, top: 1000 },
|
||||
{ ...rows[2]!, top: 1500 },
|
||||
]),
|
||||
).toBe(1100);
|
||||
});
|
||||
|
||||
it("leaves expansion of the row being read in place", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(
|
||||
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 1000 }, { ...rows[2]!, top: 1500 }]),
|
||||
).toBe(600);
|
||||
});
|
||||
|
||||
it("keeps the existing reader through prepend measurements until the user scrolls", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(0, [{ id: "reading", top: 48, height: 100 }]);
|
||||
const prepended = [
|
||||
{ id: "new", top: 2000, height: 57 },
|
||||
{ id: "reading", top: 2057, height: 100 },
|
||||
];
|
||||
expect(anchor.reconcile(0, prepended)).toBe(2009);
|
||||
expect(anchor.getRowId()).toBe("reading");
|
||||
const measured = [
|
||||
{ id: "new", top: 2000, height: 100 },
|
||||
{ id: "reading", top: 2100, height: 100 },
|
||||
];
|
||||
expect(anchor.reconcile(2009, measured)).toBe(2052);
|
||||
expect(anchor.getRowId()).toBe("reading");
|
||||
expect(anchor.reconcile(2000, measured, true)).toBe(2000);
|
||||
expect(anchor.getRowId()).toBe("new");
|
||||
});
|
||||
|
||||
it("retains the reader while the virtualizer initializes its mounted range", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.reconcile(600, [rows[2]!]);
|
||||
expect(anchor.getRowId()).toBe("reading");
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
600,
|
||||
rows.map((row) => ({ ...row, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2600);
|
||||
});
|
||||
|
||||
it("projects a prepend range without consuming the correction before layout commits", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
rows.map((row) => ({ ...row, top: row.top + 2000 })),
|
||||
true,
|
||||
),
|
||||
).toBe(2440);
|
||||
expect(anchor.getRowId()).toBe("above");
|
||||
});
|
||||
|
||||
it("repicks from user movement before applying a simultaneous layout correction", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.scroll(440);
|
||||
expect(anchor.getRowId()).toBe("above");
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
rows.map((row) => ({ ...row, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2440);
|
||||
expect(anchor.getRowId()).toBe("above");
|
||||
});
|
||||
|
||||
it("releases the old reading position for explicit navigation", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.reset();
|
||||
expect(anchor.getRowId()).toBeNull();
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
0,
|
||||
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
interface RowGeometry {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
export function createReadingAnchor() {
|
||||
let anchor: { id: string; top: number } | null = null;
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
// The first virtualized commit can precede its mounted range. Keep the
|
||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -491,10 +491,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4959.1708984375,
|
||||
});
|
||||
scrollContainer.scrollTop = 4959.1708984375;
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
@@ -504,10 +501,7 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
||||
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4960.5,
|
||||
});
|
||||
scrollContainer.scrollTop = 4960.5;
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
@@ -516,10 +510,7 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
||||
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4967,
|
||||
});
|
||||
scrollContainer.scrollTop = 4967;
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
@@ -535,7 +526,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
@@ -585,7 +576,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
@@ -621,10 +612,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -682,7 +670,7 @@ describe("createWebStreamStrategy", () => {
|
||||
const scrollElement = scrollContainer;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollElement.scrollTop = 0;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -724,10 +712,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -786,7 +771,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -798,7 +783,7 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -831,10 +816,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -892,7 +874,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -901,17 +883,17 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
|
||||
scrollContainer.scrollTop = 991;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
|
||||
scrollContainer.scrollTop = 91;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -937,10 +919,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -992,7 +971,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
scrollContainer.scrollTop = 50;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -1004,13 +983,13 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
|
||||
scrollContainer.scrollTop = 20;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
scrollContainer.scrollTop = 50;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -1038,7 +1017,7 @@ describe("createWebStreamStrategy", () => {
|
||||
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
@@ -1084,7 +1063,7 @@ describe("createWebStreamStrategy", () => {
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
@@ -1124,7 +1103,7 @@ describe("createWebStreamStrategy", () => {
|
||||
window.dispatchEvent(jitterPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
|
||||
scrollContainer.scrollTop = 800;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1132,11 +1111,11 @@ describe("createWebStreamStrategy", () => {
|
||||
nestedScroller.style.overflowY = "auto";
|
||||
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
|
||||
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
|
||||
nestedScroller.scrollTop = 100;
|
||||
scrollContainer.append(nestedScroller);
|
||||
act(() => {
|
||||
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
scrollContainer.scrollTop = 700;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
|
||||
@@ -1159,7 +1138,7 @@ describe("createWebStreamStrategy", () => {
|
||||
it("stops following output after scrollbar and middle-button upward scrolling", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
@@ -1204,7 +1183,7 @@ describe("createWebStreamStrategy", () => {
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
@@ -1232,7 +1211,7 @@ describe("createWebStreamStrategy", () => {
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
scrollContainer.scrollTop = 700;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1248,7 +1227,7 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
|
||||
scrollContainer.scrollTop = 1300;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
@@ -1276,7 +1255,7 @@ describe("createWebStreamStrategy", () => {
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
|
||||
scrollContainer.scrollTop = 900;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1295,7 +1274,7 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
|
||||
scrollContainer.scrollTop = 1400;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const autoscrollPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
@@ -1329,7 +1308,7 @@ describe("createWebStreamStrategy", () => {
|
||||
window.dispatchEvent(autoscrollPointerUp);
|
||||
window.dispatchEvent(autoscrollPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1390,7 +1369,7 @@ describe("createWebStreamStrategy", () => {
|
||||
};
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
@@ -1447,12 +1426,18 @@ describe("createWebStreamStrategy", () => {
|
||||
throw new Error("Expected retained history rows");
|
||||
}
|
||||
let rowsShifted = false;
|
||||
scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect);
|
||||
firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect);
|
||||
secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect);
|
||||
scrollContainer.getBoundingClientRect = () => ({ top: 0, height: 500, bottom: 500 }) as DOMRect;
|
||||
firstRow.getBoundingClientRect = () =>
|
||||
({ top: rowsShifted ? -116 : 0, height: 120, bottom: rowsShifted ? 4 : 120 }) as DOMRect;
|
||||
secondRow.getBoundingClientRect = () =>
|
||||
({
|
||||
top: rowsShifted ? 4 : 120,
|
||||
height: rowsShifted ? 116 : 40,
|
||||
bottom: rowsShifted ? 120 : 160,
|
||||
}) as DOMRect;
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const viewportObservation = observed.get(scrollContainer);
|
||||
expect(viewportObservation).toBeDefined();
|
||||
@@ -1483,7 +1468,7 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
|
||||
@@ -9,10 +9,13 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
defaultRangeExtractor,
|
||||
observeElementOffset,
|
||||
measureElement as measureVirtualElement,
|
||||
useVirtualizer,
|
||||
type Range as VirtualRange,
|
||||
type Virtualizer,
|
||||
} from "@tanstack/react-virtual";
|
||||
import { flushSync } from "react-dom";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -20,10 +23,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
import {
|
||||
estimateStreamItemHeight,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
} from "./web-virtualization";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
@@ -49,12 +50,6 @@ interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
}
|
||||
|
||||
interface HistoryStartPrependAnchor {
|
||||
progressKey: string;
|
||||
rowId: string;
|
||||
viewportOffset: number;
|
||||
}
|
||||
|
||||
type ScrollBehaviorLike = "auto" | "smooth";
|
||||
|
||||
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||
@@ -67,24 +62,12 @@ const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
||||
const CONTENT_PADDING_TOP_PX = 16;
|
||||
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
|
||||
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
|
||||
// A row has to clear this much of the viewport top before the next one takes over as the
|
||||
// reading position, so a row resting exactly on the edge does not flip back and forth.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
if (element.dataset.historyRowId === rowId) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -304,6 +287,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
@@ -348,14 +332,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const lastTouchClientYRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
|
||||
const historyStartPrependAnchorActiveRef = useRef(false);
|
||||
const readingAnchor = useMemo(createReadingAnchor, []);
|
||||
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
|
||||
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(null);
|
||||
@@ -389,14 +372,56 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
return indexes.length > 0 ? indexes : null;
|
||||
}, [chatFindMessageId, segments.historyVirtualized]);
|
||||
const rowVirtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
if (!chatFindRowIndexes) return visible;
|
||||
return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right);
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
// Select against the pending anchor correction, before mounting any rows at
|
||||
// the stale pre-prepend offset. The DOM correction still belongs to commit.
|
||||
const virtualizer = rowVirtualizerRef.current!;
|
||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||
const correctedOffset =
|
||||
container && measurement
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
container &&
|
||||
correctedOffset !== undefined &&
|
||||
Math.abs(correctedOffset - container.scrollTop) > 0.5
|
||||
) {
|
||||
const first = virtualizer.getVirtualItemForOffset(correctedOffset);
|
||||
const last = virtualizer.getVirtualItemForOffset(correctedOffset + container.clientHeight);
|
||||
if (first && last)
|
||||
visibleRange = { ...range, startIndex: first.index, endIndex: last.index };
|
||||
}
|
||||
return [
|
||||
...new Set([
|
||||
...defaultRangeExtractor(visibleRange),
|
||||
...(chatFindRowIndexes ?? []),
|
||||
...(anchorIndex < 0 ? [] : [anchorIndex]),
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
virtualOffsetListener.current = onOffset;
|
||||
const unsubscribe = observeElementOffset(instance, onOffset);
|
||||
return () => {
|
||||
virtualOffsetListener.current = null;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
const contentWidth = contentRef.current?.getBoundingClientRect().width;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
@@ -404,31 +429,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = segments.historyVirtualized[index];
|
||||
return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
|
||||
const measured = row && measuredRowHeights.current.get(row.id);
|
||||
if (measured && measured.width === contentWidth) return measured.height;
|
||||
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
observeElementOffset: observeVirtualOffset,
|
||||
rangeExtractor,
|
||||
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
overscan: 8,
|
||||
});
|
||||
useEffect(() => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
const viewportHeight = instance.scrollRect?.height ?? 0;
|
||||
const scrollOffset = instance.scrollOffset ?? 0;
|
||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
|
||||
return shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
|
||||
rowStart: item.start,
|
||||
scrollOffset,
|
||||
remainingDistanceFromBottom: remainingDistance,
|
||||
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
|
||||
};
|
||||
}, [rowVirtualizer]);
|
||||
rowVirtualizerRef.current = rowVirtualizer;
|
||||
// Scroll correction belongs to the viewport's committed layout. The virtualizer
|
||||
// otherwise writes scrollTop before React applies its new row positions.
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
||||
@@ -442,7 +455,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled,
|
||||
// An explicit jump owns the landing position. Revealing another local
|
||||
// page here changes the virtual index before the target has mounted.
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
@@ -453,29 +468,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!isHistoryStartLoadingOperation(transition.state)) {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
|
||||
const anchorElement =
|
||||
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
|
||||
if (scrollContainer && anchorRow && anchorElement) {
|
||||
historyStartPrependAnchorRef.current = {
|
||||
progressKey: olderHistoryProgressKey,
|
||||
rowId: anchorRow.id,
|
||||
viewportOffset:
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
};
|
||||
} else {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
@@ -512,27 +507,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const applyHistoryStartPrependAnchor = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (
|
||||
!scrollContainer ||
|
||||
!contentNode ||
|
||||
!anchor ||
|
||||
!historyStartPrependAnchorActiveRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
|
||||
if (!anchorElement) {
|
||||
return;
|
||||
}
|
||||
const viewportOffset =
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
|
||||
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -543,24 +517,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return (
|
||||
!input ||
|
||||
input.isLoadingOlderHistory ||
|
||||
pendingVirtualRowMeasureFramesRef.current.size > 0
|
||||
);
|
||||
return !input || input.isLoadingOlderHistory;
|
||||
},
|
||||
onFrame: applyHistoryStartPrependAnchor,
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
@@ -569,59 +536,35 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduler.schedule();
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
applyHistoryStartPrependAnchor();
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) {
|
||||
rowVirtualizer.measureElement(null);
|
||||
return;
|
||||
}
|
||||
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||
const existingFrame = pendingFrames.get(node);
|
||||
if (existingFrame !== undefined) {
|
||||
window.cancelAnimationFrame(existingFrame);
|
||||
}
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
pendingFrames.delete(node);
|
||||
if (isActiveRef.current && node.isConnected) {
|
||||
rowVirtualizer.measureElement(node);
|
||||
}
|
||||
});
|
||||
pendingFrames.set(node, frame);
|
||||
},
|
||||
// ResizeObserver runs before paint. Batch measurements and commit React's new
|
||||
// positions here so a resized image cannot overlap rows for an extra frame.
|
||||
// Use resizeItem exclusively; measureElement would install a second observer.
|
||||
const virtualRowObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
if (!isActiveRef.current) return;
|
||||
flushSync(() => {
|
||||
for (const entry of entries) {
|
||||
const element = entry.target as HTMLElement;
|
||||
if (!element.isConnected) continue;
|
||||
rowVirtualizer.resizeItem(
|
||||
Number(element.dataset.index),
|
||||
measureVirtualElement(element, entry, rowVirtualizer),
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
[rowVirtualizer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||
return () => {
|
||||
for (const frame of pendingFrames.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingFrames.clear();
|
||||
};
|
||||
}, []);
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) return;
|
||||
virtualRowObserver.observe(node, { box: "border-box" });
|
||||
return () => virtualRowObserver.unobserve(node);
|
||||
},
|
||||
[virtualRowObserver],
|
||||
);
|
||||
useEffect(() => () => virtualRowObserver.disconnect(), [virtualRowObserver]);
|
||||
|
||||
const cancelPendingStickToBottom = useCallback(() => {
|
||||
const pendingFrame = pendingAutoScrollFrameRef.current;
|
||||
@@ -660,10 +603,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingVirtualRowMeasureFramesRef.current.clear();
|
||||
clearMouseScrollGesture();
|
||||
clearUpwardInputEvidence();
|
||||
lastTouchClientYRef.current = null;
|
||||
@@ -731,19 +670,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onReadingPositionChange(null);
|
||||
return;
|
||||
}
|
||||
const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX;
|
||||
let readingRowId: string | null = null;
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
const rowId = element.dataset.historyRowId;
|
||||
if (!rowId) {
|
||||
continue;
|
||||
}
|
||||
readingRowId = rowId;
|
||||
if (element.getBoundingClientRect().bottom > readingLine) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
onReadingPositionChange(readingRowId);
|
||||
onReadingPositionChange(readingAnchor.getReadingRowId(scrollContainer.scrollTop));
|
||||
});
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
@@ -769,6 +696,44 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearBottomChange,
|
||||
});
|
||||
|
||||
const reconcileReadingPosition = useStableEvent(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!isActiveRef.current || !container || !content) return;
|
||||
const viewportTop = container.getBoundingClientRect().top;
|
||||
const scrollTop = container.scrollTop;
|
||||
const width = content.getBoundingClientRect().width;
|
||||
const rows = Array.from(
|
||||
content.querySelectorAll<HTMLElement>("[data-history-row-id]"),
|
||||
(element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const id = element.dataset.historyRowId!;
|
||||
measuredRowHeights.current.set(id, { width, height: bounds.height });
|
||||
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
|
||||
},
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(correctedTop - container.scrollTop) > 0.5) {
|
||||
container.scrollTop = correctedTop;
|
||||
lastKnownScrollTopRef.current = container.scrollTop;
|
||||
// The DOM scroll event arrives later. Fill the corrected virtual range in
|
||||
// this layout so the reader does not get a frame containing only the pin.
|
||||
virtualOffsetListener.current?.(container.scrollTop, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply row positions and their scroll correction in the same paint, including
|
||||
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
|
||||
useLayoutEffect(() => {
|
||||
reconcileReadingPosition();
|
||||
}, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
@@ -801,11 +766,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
if (!followOutputRef.current && !isJumpSettling() && (scrolledUp || scrolledDown)) {
|
||||
readingAnchor.scroll(currentScrollTop);
|
||||
}
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
@@ -814,8 +783,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
readingAnchor.reset();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -826,7 +794,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -959,9 +927,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
} else {
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
applyHistoryStartPrependAnchor();
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
@@ -980,7 +946,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
reconcileReadingPosition,
|
||||
evaluateHistoryStart,
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
@@ -1101,7 +1067,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastTouchClientYRef.current = null;
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
|
||||
// Read user movement before the virtualizer's bubbling listener flushes a
|
||||
// layout correction. Scroll events reuse the last committed row geometry.
|
||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true, capture: true });
|
||||
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
|
||||
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
|
||||
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
|
||||
@@ -1115,7 +1083,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", handleDomScroll);
|
||||
scrollContainer.removeEventListener("scroll", handleDomScroll, true);
|
||||
scrollContainer.removeEventListener("wheel", handleWheel);
|
||||
scrollContainer.removeEventListener("keydown", handleKeyDown);
|
||||
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
|
||||
@@ -1188,9 +1156,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
overflowX: "hidden",
|
||||
overflowY: scrollEnabled ? "auto" : "hidden",
|
||||
overscrollBehaviorY: "contain",
|
||||
// The browser still anchors delayed image growth while following output.
|
||||
// Detached reading has one owner: reconcileReadingPosition.
|
||||
overflowAnchor: followOutput ? "auto" : "none",
|
||||
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
|
||||
};
|
||||
}, [isMobileBreakpoint, scrollEnabled]);
|
||||
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
|
||||
const viewportStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: "relative",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -94,6 +95,7 @@ export interface StreamRenderInput {
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export interface ResolveStreamRenderStrategyInput {
|
||||
|
||||
@@ -1131,6 +1131,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
imageContext: { serverId: resolvedServerId, workspaceRoot },
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./web-virtualization";
|
||||
@@ -124,8 +123,18 @@ describe("splitWebVirtualizedHistory", () => {
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
it("uses compact estimates for collapsed tool sequence rows", () => {
|
||||
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item: toolCall("tool", 1),
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBe(40);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item: thought("thought", 2),
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBe(40);
|
||||
});
|
||||
|
||||
it("uses a larger estimate for user messages with image attachments", () => {
|
||||
@@ -147,7 +156,40 @@ describe("estimateStreamItemHeight", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH })).toBe(
|
||||
220,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses local image dimensions only from the row's server and workspace", () => {
|
||||
clearAssistantImageMetadataCache();
|
||||
const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" };
|
||||
setAssistantImageMetadata(
|
||||
{ source: "portrait.png", ...imageContext },
|
||||
{ width: 800, height: 1600 },
|
||||
);
|
||||
const item: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "local-image",
|
||||
text: "",
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth: 608,
|
||||
imageContext: { ...imageContext, workspaceRoot: "/workspace/two" },
|
||||
}),
|
||||
).toBe(464);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth: 608,
|
||||
imageContext: { ...imageContext, serverId: "server-two" },
|
||||
}),
|
||||
).toBe(464);
|
||||
});
|
||||
|
||||
it("uses cached assistant image metadata when available", () => {
|
||||
@@ -166,56 +208,9 @@ describe("estimateStreamItemHeight", () => {
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual row resize anchoring", () => {
|
||||
it("does not move the viewport when a visible row expands while detached", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 1200,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the reading position when a row above the viewport changes size", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves history prepend and bottom following to their existing anchors", () => {
|
||||
const baseInput = {
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: false,
|
||||
remainingDistanceFromBottom: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }),
|
||||
).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
|
||||
import {
|
||||
@@ -10,21 +11,6 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||
|
||||
export function shouldAdjustScrollForVirtualRowResize(input: {
|
||||
isHistoryStartPrependActive: boolean;
|
||||
rowStart: number;
|
||||
scrollOffset: number;
|
||||
remainingDistanceFromBottom: number;
|
||||
bottomThreshold: number;
|
||||
}): boolean {
|
||||
if (input.isHistoryStartPrependActive) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
|
||||
);
|
||||
}
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
};
|
||||
@@ -58,12 +44,28 @@ export interface WebVirtualizedHistoryWindow {
|
||||
mountedEntries: IndexedStreamItem[];
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
|
||||
interface StreamItemHeightEstimateInput {
|
||||
item: StreamItem;
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}: StreamItemHeightEstimateInput): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
|
||||
return (
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: item.text,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}) ?? 220
|
||||
);
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { LucideProvider } from "lucide-react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
@@ -45,6 +46,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -112,7 +114,7 @@ import {
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -606,6 +608,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostConfirmationSheet />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
@@ -970,11 +973,13 @@ function RootAppTree() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</LucideProvider>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
@@ -7,10 +7,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 4,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
scanAgainButton: {
|
||||
marginTop: theme.spacing[6],
|
||||
},
|
||||
helperText: {
|
||||
marginTop: theme.spacing[6],
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
|
||||
return raw;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -129,9 +124,6 @@ export default function PairScanScreen() {
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -144,6 +136,19 @@ export default function PairScanScreen() {
|
||||
[router, source],
|
||||
);
|
||||
|
||||
const [scan] = useState(() =>
|
||||
openPairScan({
|
||||
importConnectionLink: (link) =>
|
||||
getHostRuntimeStore().importConnectionLink(
|
||||
link,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
}),
|
||||
);
|
||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||
useEffect(() => () => scan.close(), [scan]);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
try {
|
||||
router.back();
|
||||
@@ -159,30 +164,12 @@ export default function PairScanScreen() {
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
const handleScan = useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
|
||||
const store = getHostRuntimeStore();
|
||||
if (passwordOfferUrl) return;
|
||||
setIsPairing(true);
|
||||
setScanError(null);
|
||||
void store
|
||||
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
||||
.then((outcome) => {
|
||||
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
||||
else setPasswordOfferUrl(offerUrl);
|
||||
return outcome;
|
||||
})
|
||||
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
||||
.finally(() => setIsPairing(false));
|
||||
},
|
||||
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
|
||||
(result: BarcodeScanningResult) =>
|
||||
scan.scan(typeof result.data === "string" ? result.data : ""),
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
@@ -240,24 +227,38 @@ export default function PairScanScreen() {
|
||||
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
|
||||
onBarcodeScanned={handleScan}
|
||||
/>
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
style={styles.scanAgainButton}
|
||||
onPress={scan.scanAgain}
|
||||
testID="pair-scan-again"
|
||||
>
|
||||
{t("pairing.connectionMethods.scanQr.title")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PairLinkModal
|
||||
visible={passwordOfferUrl !== null}
|
||||
initialUrl={passwordOfferUrl ?? undefined}
|
||||
initialPasswordRequired
|
||||
onClose={closePasswordModal}
|
||||
visible={scanState.status === "passwordRequired"}
|
||||
passwordRequired={
|
||||
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
|
||||
}
|
||||
onClose={scan.closePassword}
|
||||
onSaved={savePasswordPairing}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -415,8 +415,8 @@ export function useAssistantImage({
|
||||
const preview = dataImage ? dataImagePreview : filePreview;
|
||||
const previewUri = preview.status === "loaded" ? preview.uri : null;
|
||||
const uri = directUri ?? previewUri;
|
||||
const cachedMetadata = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
|
||||
const cachedAspectRatio = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null,
|
||||
[serverId, source, workspaceRoot],
|
||||
);
|
||||
const [lifecycle, dispatchLifecycle] = useReducer(
|
||||
@@ -454,9 +454,9 @@ export function useAssistantImage({
|
||||
dispatch({
|
||||
type: "preview_created",
|
||||
uri,
|
||||
aspectRatio: cachedMetadata?.aspectRatio ?? null,
|
||||
aspectRatio: cachedAspectRatio,
|
||||
});
|
||||
}, [cachedMetadata, dispatch, uri]);
|
||||
}, [cachedAspectRatio, dispatch, uri]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
if (uri) {
|
||||
@@ -484,7 +484,7 @@ export function useAssistantImage({
|
||||
const metadata = dimensions
|
||||
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
|
||||
: null;
|
||||
const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null;
|
||||
const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio;
|
||||
if (!aspectRatio) {
|
||||
dispatch({
|
||||
type: "failed",
|
||||
@@ -495,7 +495,7 @@ export function useAssistantImage({
|
||||
}
|
||||
dispatch({ type: "image_loaded", uri, aspectRatio });
|
||||
},
|
||||
[cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot],
|
||||
[cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot],
|
||||
);
|
||||
|
||||
const acquisitionFailure = getAcquisitionFailure({
|
||||
@@ -539,6 +539,6 @@ export function useAssistantImage({
|
||||
return {
|
||||
status: "loading",
|
||||
binding,
|
||||
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
|
||||
aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export interface AudioEngine {
|
||||
toggleMute(): boolean;
|
||||
isMuted(): boolean;
|
||||
|
||||
play(audio: AudioPlaybackSource): Promise<number>;
|
||||
play(audio: AudioPlaybackSource, signal?: AbortSignal): Promise<number>;
|
||||
stop(): void;
|
||||
clearQueue(): void;
|
||||
isPlaying(): boolean;
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
|
||||
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
import { playFile } from "./file-playback";
|
||||
import { playPcm16 } from "./pcm";
|
||||
|
||||
export function createAudioEngine(
|
||||
callbacks: AudioEngineCallbacks,
|
||||
_options?: { traceLabel?: string },
|
||||
): AudioEngine {
|
||||
const native = require("@getpaseo/expo-two-way-audio");
|
||||
|
||||
const refs: {
|
||||
initialized: boolean;
|
||||
captureActive: boolean;
|
||||
muted: boolean;
|
||||
destroyed: boolean;
|
||||
} = {
|
||||
initialized: false,
|
||||
captureActive: false,
|
||||
muted: false,
|
||||
destroyed: false,
|
||||
};
|
||||
|
||||
const microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event: { data: Uint8Array }) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
const pcm = event.data;
|
||||
callbacks.onCaptureData(pcm);
|
||||
},
|
||||
);
|
||||
const volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event: { data: number }) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
callbacks.onVolumeLevel(level);
|
||||
},
|
||||
);
|
||||
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onAudioInterruption",
|
||||
(event: { data: string }) => {
|
||||
if (event.data !== "blocked") {
|
||||
return;
|
||||
}
|
||||
const wasCaptureActive = refs.captureActive;
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (wasCaptureActive) {
|
||||
callbacks.onInterruption?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (refs.initialized) {
|
||||
return;
|
||||
}
|
||||
const success = await native.initialize();
|
||||
if (!success) {
|
||||
throw new Error("expo-two-way-audio: native initialize() returned false");
|
||||
}
|
||||
refs.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the OS audio session as soon as we are neither capturing nor playing.
|
||||
* Holding it keeps the user's background music paused — on iOS the non-mixing
|
||||
* `.playAndRecord` category survives backgrounding and is re-asserted on every
|
||||
* foreground, so an unreleased session means their music never comes back.
|
||||
*/
|
||||
function releaseSessionIfIdle(): void {
|
||||
if (!refs.initialized || refs.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (refs.captureActive || playback.isPlaying()) {
|
||||
return;
|
||||
}
|
||||
// The wrapper no-ops on binaries whose native module predates this function.
|
||||
native.releaseAudioSession();
|
||||
}
|
||||
|
||||
async function ensureMicrophonePermission(): Promise<void> {
|
||||
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
||||
if (!permission?.granted) {
|
||||
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
||||
}
|
||||
if (!permission?.granted) {
|
||||
throw new Error(
|
||||
"Microphone permission is required to capture audio. Please enable microphone access in system settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let nextFileId = 0;
|
||||
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
|
||||
const bytes = new Uint8Array(await audio.arrayBuffer());
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
if (audio.type.startsWith("audio/pcm")) {
|
||||
await ensureInitialized();
|
||||
return playPcm16(bytes, audio.type, signal, native);
|
||||
}
|
||||
// Capture owns its audio session while active. File playback alone must not
|
||||
// initialize the microphone or the native two-way engine.
|
||||
if (!refs.captureActive) {
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
allowsRecording: false,
|
||||
interruptionMode: "duckOthers",
|
||||
interruptionModeAndroid: "duckOthers",
|
||||
});
|
||||
}
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
// AVPlayer needs a file extension to recognize local encoded audio on iOS.
|
||||
const extension =
|
||||
{
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/wave": "wav",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/aac": "aac",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/flac": "flac",
|
||||
}[audio.type.split(";")[0].trim()] ?? "audio";
|
||||
const file = new File(Paths.cache, `paseo-audio-${Date.now()}-${nextFileId++}.${extension}`);
|
||||
try {
|
||||
file.write(bytes);
|
||||
const player = createAudioPlayer(file.uri, {
|
||||
updateInterval: 100,
|
||||
keepAudioSessionActive: refs.captureActive,
|
||||
});
|
||||
return await playFile(player, signal);
|
||||
} finally {
|
||||
if (file.exists) file.delete();
|
||||
}
|
||||
}
|
||||
const playback = createPlaybackQueue(playAudio, releaseSessionIfIdle);
|
||||
|
||||
return {
|
||||
async initialize() {
|
||||
await ensureInitialized();
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
if (refs.destroyed) {
|
||||
return;
|
||||
}
|
||||
refs.destroyed = true;
|
||||
playback.destroy();
|
||||
if (refs.captureActive) {
|
||||
native.toggleRecording(false);
|
||||
refs.captureActive = false;
|
||||
}
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (refs.initialized) {
|
||||
native.tearDown();
|
||||
refs.initialized = false;
|
||||
}
|
||||
microphoneSubscription.remove();
|
||||
volumeSubscription.remove();
|
||||
interruptionSubscription.remove();
|
||||
},
|
||||
|
||||
async startCapture() {
|
||||
if (refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
const isRecording = native.toggleRecording(true);
|
||||
if (!isRecording) {
|
||||
throw new Error(
|
||||
"Microphone capture could not start because Android audio focus is unavailable.",
|
||||
);
|
||||
}
|
||||
refs.captureActive = true;
|
||||
} catch (error) {
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
callbacks.onError?.(wrapped);
|
||||
throw wrapped;
|
||||
}
|
||||
},
|
||||
|
||||
async stopCapture() {
|
||||
if (refs.captureActive) {
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
releaseSessionIfIdle();
|
||||
},
|
||||
|
||||
toggleMute() {
|
||||
refs.muted = !refs.muted;
|
||||
if (refs.muted) {
|
||||
callbacks.onVolumeLevel(0);
|
||||
}
|
||||
return refs.muted;
|
||||
},
|
||||
|
||||
isMuted() {
|
||||
return refs.muted;
|
||||
},
|
||||
|
||||
play: playback.play,
|
||||
stop: playback.stop,
|
||||
clearQueue: playback.clearQueue,
|
||||
isPlaying: playback.isPlaying,
|
||||
};
|
||||
}
|
||||
+49
-141
@@ -1,15 +1,8 @@
|
||||
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
AudioPlaybackSource,
|
||||
} from "@/voice/audio-engine-types";
|
||||
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
|
||||
interface QueuedAudio {
|
||||
audio: AudioPlaybackSource;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
|
||||
function getAudioContextCtor(): typeof AudioContext | null {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -21,40 +14,6 @@ function getAudioContextCtor(): typeof AudioContext | null {
|
||||
return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null;
|
||||
}
|
||||
|
||||
function floatToInt16(sample: number): number {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
|
||||
}
|
||||
|
||||
function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Uint8Array {
|
||||
if (input.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const ratio = inputRate / outputRate;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const output = new Int16Array(outputLength);
|
||||
for (let i = 0; i < outputLength; i += 1) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
output[i] = floatToInt16(sample);
|
||||
}
|
||||
|
||||
return new Uint8Array(output.buffer, output.byteOffset, output.byteLength);
|
||||
}
|
||||
|
||||
function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rate = Number(match[1]);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
function pcm16LeToAudioBuffer(
|
||||
context: AudioContext,
|
||||
bytes: Uint8Array,
|
||||
@@ -98,14 +57,6 @@ export function createAudioEngine(
|
||||
gain: GainNode | null;
|
||||
started: boolean;
|
||||
muted: boolean;
|
||||
queue: QueuedAudio[];
|
||||
processingQueue: boolean;
|
||||
activePlayback: {
|
||||
source: AudioBufferSourceNode;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null;
|
||||
} = {
|
||||
playbackContext: null,
|
||||
captureContext: null,
|
||||
@@ -115,29 +66,31 @@ export function createAudioEngine(
|
||||
gain: null,
|
||||
started: false,
|
||||
muted: false,
|
||||
queue: [],
|
||||
processingQueue: false,
|
||||
activePlayback: null,
|
||||
};
|
||||
|
||||
async function ensurePlaybackContext(): Promise<AudioContext> {
|
||||
if (refs.playbackContext) {
|
||||
if (refs.playbackContext.state === "suspended") {
|
||||
await refs.playbackContext.resume().catch(() => undefined);
|
||||
}
|
||||
return refs.playbackContext;
|
||||
if (!refs.playbackContext) {
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) throw new Error("AudioContext unavailable");
|
||||
refs.playbackContext = new AudioContextCtor();
|
||||
}
|
||||
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) {
|
||||
throw new Error("AudioContext unavailable");
|
||||
}
|
||||
|
||||
const context = new AudioContextCtor();
|
||||
const context = refs.playbackContext;
|
||||
if (context.state === "suspended") {
|
||||
await context.resume().catch(() => undefined);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
context.resume(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error("Audio playback requires a user interaction")),
|
||||
2000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
refs.playbackContext = context;
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -162,7 +115,7 @@ export function createAudioEngine(
|
||||
return context;
|
||||
}
|
||||
|
||||
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
||||
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
|
||||
const context = await ensurePlaybackContext();
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const type = (audio.type || "").toLowerCase();
|
||||
@@ -174,53 +127,39 @@ export function createAudioEngine(
|
||||
)
|
||||
: await decodeAudioData(context, arrayBuffer);
|
||||
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
const durationSec = audioBuffer.duration;
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(context.destination);
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
refs.activePlayback = { source, resolve, reject, settled: false };
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
const active = refs.activePlayback;
|
||||
if (!active || active.source !== source || active.settled) {
|
||||
return;
|
||||
}
|
||||
active.settled = true;
|
||||
refs.activePlayback = null;
|
||||
fn();
|
||||
const abort = () => {
|
||||
source.stop();
|
||||
source.disconnect();
|
||||
reject(new Error("Playback stopped"));
|
||||
};
|
||||
|
||||
source.addEventListener("ended", () => {
|
||||
settle(() => resolve(durationSec));
|
||||
});
|
||||
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
source.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
source.disconnect();
|
||||
resolve(durationSec);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
try {
|
||||
source.start();
|
||||
} catch (error) {
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
signal.removeEventListener("abort", abort);
|
||||
source.disconnect();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (refs.processingQueue || refs.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
refs.processingQueue = true;
|
||||
while (refs.queue.length > 0) {
|
||||
const item = refs.queue.shift()!;
|
||||
try {
|
||||
const duration = await playAudio(item.audio);
|
||||
item.resolve(duration);
|
||||
} catch (error) {
|
||||
item.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
refs.processingQueue = false;
|
||||
}
|
||||
const playback = createPlaybackQueue(playAudio);
|
||||
|
||||
async function stopCapture(): Promise<void> {
|
||||
refs.started = false;
|
||||
@@ -263,8 +202,7 @@ export function createAudioEngine(
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
playback.destroy();
|
||||
await stopCapture();
|
||||
|
||||
const playbackContext = refs.playbackContext;
|
||||
@@ -334,7 +272,8 @@ export function createAudioEngine(
|
||||
return;
|
||||
}
|
||||
|
||||
callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000));
|
||||
const pcm = resampleToPcm16(input, context.sampleRate, 16000);
|
||||
callbacks.onCaptureData(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
@@ -370,40 +309,9 @@ export function createAudioEngine(
|
||||
return refs.muted;
|
||||
},
|
||||
|
||||
async play(audio: AudioPlaybackSource) {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
refs.queue.push({ audio, resolve, reject });
|
||||
if (!refs.processingQueue) {
|
||||
void processQueue();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (refs.activePlayback) {
|
||||
const active = refs.activePlayback;
|
||||
refs.activePlayback = null;
|
||||
try {
|
||||
active.source.stop();
|
||||
} catch {
|
||||
// Ignore best-effort stop errors.
|
||||
}
|
||||
if (!active.settled) {
|
||||
active.settled = true;
|
||||
active.reject(new Error("Playback stopped"));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearQueue() {
|
||||
while (refs.queue.length > 0) {
|
||||
refs.queue.shift()!.reject(new Error("Playback stopped"));
|
||||
}
|
||||
refs.processingQueue = false;
|
||||
},
|
||||
|
||||
isPlaying() {
|
||||
return refs.activePlayback !== null;
|
||||
},
|
||||
play: playback.play,
|
||||
stop: playback.stop,
|
||||
clearQueue: playback.clearQueue,
|
||||
isPlaying: playback.isPlaying,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface FilePlaybackStatus {
|
||||
playbackState: string;
|
||||
isLoaded: boolean;
|
||||
didJustFinish: boolean;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FilePlayer {
|
||||
play(): void;
|
||||
remove(): void;
|
||||
addListener(
|
||||
event: "playbackStatusUpdate",
|
||||
listener: (status: FilePlaybackStatus) => void,
|
||||
): { remove(): void };
|
||||
}
|
||||
|
||||
/** Own completion, load failure and resource release for the native file backend. */
|
||||
export function playFile(player: FilePlayer, signal: AbortSignal): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
// expo-audio 1.x does not report Android decoder errors. A local file that
|
||||
// never becomes loaded must still release its queue slot and temporary file.
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (error?: unknown, duration = 0) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", abort);
|
||||
subscription.remove();
|
||||
player.remove();
|
||||
if (error) reject(error);
|
||||
else resolve(duration);
|
||||
};
|
||||
const abort = () => finish(new Error("Playback stopped"));
|
||||
let loaded = false;
|
||||
const subscription = player.addListener("playbackStatusUpdate", (status) => {
|
||||
if (status.playbackState === "error" || status.playbackState === "failed") {
|
||||
finish(new Error("Audio file could not be decoded"));
|
||||
} else if (status.didJustFinish) {
|
||||
finish(undefined, status.duration);
|
||||
} else if (status.isLoaded && status.duration > 0 && !loaded) {
|
||||
loaded = true;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(
|
||||
() => finish(new Error("Audio playback timed out")),
|
||||
(status.duration + 30) * 1000,
|
||||
);
|
||||
}
|
||||
});
|
||||
timeout = setTimeout(() => finish(new Error("Audio file could not be loaded")), 15000);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
else {
|
||||
try {
|
||||
player.play();
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createAudioEngine } from "./audio-engine";
|
||||
export type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
export { createPlayAudio } from "./play-audio";
|
||||
@@ -0,0 +1,124 @@
|
||||
const floatToInt16 = (sample: number): number => {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
|
||||
};
|
||||
|
||||
export const resampleToPcm16 = (
|
||||
input: Float32Array,
|
||||
inputRate: number,
|
||||
outputRate: number,
|
||||
): Int16Array => {
|
||||
if (input.length === 0) {
|
||||
return new Int16Array(0);
|
||||
}
|
||||
if (inputRate === outputRate) {
|
||||
const out = new Int16Array(input.length);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
out[i] = floatToInt16(input[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ratio = inputRate / outputRate;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const out = new Int16Array(outputLength);
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
out[i] = floatToInt16(sample);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rate = Number(match[1]);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const out = new Uint8Array(outputSamples * 2);
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const i = sampleIndex * 2;
|
||||
if (i + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[i];
|
||||
const hi = pcm[i + 1];
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const i = sampleIndex * 2;
|
||||
out[i] = clamped & 0xff;
|
||||
out[i + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
const srcPos = i * ratio;
|
||||
const i0 = Math.floor(srcPos);
|
||||
const frac = srcPos - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
interface PcmOutput {
|
||||
resumePlayback(): void;
|
||||
playPCMData(bytes: Uint8Array): void;
|
||||
stopPlayback(): void;
|
||||
}
|
||||
|
||||
/** Keep voice output on the native communication engine and its echo reference. */
|
||||
export function playPcm16(
|
||||
bytes: Uint8Array,
|
||||
mimeType: string,
|
||||
signal: AbortSignal,
|
||||
output: PcmOutput,
|
||||
): Promise<number> {
|
||||
if (signal.aborted) return Promise.reject(new Error("Playback stopped"));
|
||||
const pcm = resamplePcm16(bytes, parsePcmSampleRate(mimeType) ?? 24000, 16000);
|
||||
const duration = pcm.length / 2 / 16000;
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const abort = () => {
|
||||
clearTimeout(timeout);
|
||||
output.stopPlayback();
|
||||
reject(new Error("Playback stopped"));
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
output.resumePlayback();
|
||||
output.playPCMData(pcm);
|
||||
timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
resolve(duration);
|
||||
}, duration * 1000);
|
||||
} catch (error) {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createPlayAudio } from "./play-audio";
|
||||
import { playPcm16, parsePcmSampleRate, resampleToPcm16 } from "./pcm";
|
||||
import { playFile, type FilePlaybackStatus, type FilePlayer } from "./file-playback";
|
||||
|
||||
const source = { base64: "UklGRg==", mimeType: "audio/wav" };
|
||||
test("passes RPC file bytes to the shared engine and awaits completion", async () => {
|
||||
const owner = new AbortController();
|
||||
let finish!: () => void;
|
||||
const played = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let completed = false;
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
async play(audio, signal) {
|
||||
expect(signal).toBe(owner.signal);
|
||||
expect(audio.type).toBe("audio/wav");
|
||||
expect(audio.size).toBe(4);
|
||||
expect(new Uint8Array(await audio.arrayBuffer())).toEqual(new Uint8Array([82, 73, 70, 70]));
|
||||
await played;
|
||||
return 1;
|
||||
},
|
||||
},
|
||||
owner.signal,
|
||||
);
|
||||
const result = play(source).then(() => {
|
||||
completed = true;
|
||||
return undefined;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
finish();
|
||||
await result;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ ...source, base64: "garbage!" },
|
||||
{ ...source, base64: "" },
|
||||
{ ...source, mimeType: "text/plain" },
|
||||
])("rejects invalid input before reaching a decoder: %o", async (input) => {
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
play() {
|
||||
throw new Error("Reached decoder");
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await expect(play(input)).rejects.toThrow(/^Audio must/);
|
||||
});
|
||||
|
||||
test("rejects calls from an unloaded plugin", async () => {
|
||||
const owner = new AbortController();
|
||||
owner.abort(new Error("Plugin unloaded"));
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
play() {
|
||||
throw new Error("Reached decoder");
|
||||
},
|
||||
},
|
||||
owner.signal,
|
||||
);
|
||||
await expect(play(source)).rejects.toThrow("Playback stopped");
|
||||
});
|
||||
|
||||
test("voice PCM retains its native output and 16 kHz samples", async () => {
|
||||
const pcm = new Uint8Array([0, 0, 255, 127, 0, 128]);
|
||||
let written: Uint8Array | undefined;
|
||||
const duration = await playPcm16(
|
||||
pcm,
|
||||
"audio/pcm;rate=16000;bits=16",
|
||||
new AbortController().signal,
|
||||
{
|
||||
resumePlayback() {},
|
||||
playPCMData(bytes) {
|
||||
written = bytes;
|
||||
},
|
||||
stopPlayback() {},
|
||||
},
|
||||
);
|
||||
expect(written).toEqual(pcm);
|
||||
expect(duration).toBe(3 / 16000);
|
||||
});
|
||||
|
||||
test("voice PCM cancellation stops native output and rejects", async () => {
|
||||
let stopped = false;
|
||||
const owner = new AbortController();
|
||||
const result = playPcm16(new Uint8Array(32000), "audio/pcm;rate=16000;bits=16", owner.signal, {
|
||||
resumePlayback() {},
|
||||
playPCMData() {},
|
||||
stopPlayback() {
|
||||
stopped = true;
|
||||
},
|
||||
});
|
||||
owner.abort();
|
||||
await expect(result).rejects.toThrow("Playback stopped");
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
|
||||
function filePlayer() {
|
||||
let listener: (status: FilePlaybackStatus) => void = () => {};
|
||||
let removed = false;
|
||||
let started = false;
|
||||
const player: FilePlayer = {
|
||||
play() {
|
||||
started = true;
|
||||
},
|
||||
remove() {
|
||||
removed = true;
|
||||
},
|
||||
addListener(_event, callback) {
|
||||
listener = callback;
|
||||
return {
|
||||
remove() {
|
||||
listener = () => {};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
player,
|
||||
status: (status: Partial<FilePlaybackStatus>) =>
|
||||
listener({
|
||||
playbackState: "ready",
|
||||
duration: 1,
|
||||
didJustFinish: false,
|
||||
isLoaded: true,
|
||||
...status,
|
||||
}),
|
||||
removed: () => removed,
|
||||
started: () => started,
|
||||
};
|
||||
}
|
||||
|
||||
test("native file playback waits for completion and releases its player", async () => {
|
||||
const h = filePlayer();
|
||||
const promise = playFile(h.player, new AbortController().signal);
|
||||
expect(h.started()).toBe(true);
|
||||
h.status({ didJustFinish: true });
|
||||
await expect(promise).resolves.toBe(1);
|
||||
expect(h.removed()).toBe(true);
|
||||
});
|
||||
|
||||
test("native decoder errors reject and release the player", async () => {
|
||||
const h = filePlayer();
|
||||
const promise = playFile(h.player, new AbortController().signal);
|
||||
h.status({ playbackState: "failed" });
|
||||
await expect(promise).rejects.toThrow("could not be decoded");
|
||||
expect(h.removed()).toBe(true);
|
||||
});
|
||||
|
||||
test("native cancellation releases playback and settles the caller", async () => {
|
||||
const h = filePlayer();
|
||||
const owner = new AbortController();
|
||||
const promise = playFile(h.player, owner.signal);
|
||||
owner.abort(new Error("Plugin unloaded"));
|
||||
await expect(promise).rejects.toThrow("Playback stopped");
|
||||
expect(h.removed()).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["audio/pcm;rate=16000;bits=16", "AUDIO/PCM; rate=16000"])(
|
||||
"rejects raw PCM instead of initializing voice capture: %s",
|
||||
async (mimeType) => {
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
play: async () => {
|
||||
throw new Error("Reached engine");
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await expect(play({ base64: "AAAAAA==", mimeType })).rejects.toThrow(
|
||||
"Pass an audio file, such as WAV or MP3",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Voice capture and browser dictation use this same PCM boundary.
|
||||
test("capture PCM preserves clipping, signed samples, and empty input", () => {
|
||||
expect(resampleToPcm16(new Float32Array([-2, -1, -0.5, 0, 0.5, 1, 2]), 16000, 16000)).toEqual(
|
||||
new Int16Array([-32768, -32768, -16384, 0, 16384, 32767, 32767]),
|
||||
);
|
||||
expect(resampleToPcm16(new Float32Array(), 48000, 16000)).toEqual(new Int16Array());
|
||||
});
|
||||
|
||||
test("capture PCM resamples with interpolation and preserves the last sample", () => {
|
||||
expect(resampleToPcm16(new Float32Array([-1, 0, 1]), 24000, 16000)).toEqual(
|
||||
new Int16Array([-32768, 16384]),
|
||||
);
|
||||
expect(resampleToPcm16(new Float32Array([0, 1]), 8000, 16000)).toEqual(
|
||||
new Int16Array([0, 16384, 32767, 32767]),
|
||||
);
|
||||
});
|
||||
|
||||
test("voice playback shares PCM sample-rate parsing", () => {
|
||||
expect(parsePcmSampleRate("audio/pcm;rate=16000;bits=16")).toBe(16000);
|
||||
expect(parsePcmSampleRate("audio/PCM;RATE=48000")).toBe(48000);
|
||||
expect(parsePcmSampleRate("audio/pcm")).toBeNull();
|
||||
expect(parsePcmSampleRate("audio/pcm;rate=0")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Buffer } from "buffer";
|
||||
import type { AudioEngine } from "./audio-engine-types";
|
||||
|
||||
/** RPC-friendly file input. No browser Blob, URL, or platform knowledge is needed. */
|
||||
export function createPlayAudio(engine: Pick<AudioEngine, "play">, signal: AbortSignal) {
|
||||
return async (source: { base64: string; mimeType: string }): Promise<void> => {
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
if (
|
||||
!source ||
|
||||
typeof source.base64 !== "string" ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(source.base64) ||
|
||||
source.base64.length % 4 !== 0 ||
|
||||
!source.base64.length
|
||||
) {
|
||||
throw new Error("Audio must contain a non-empty base64-encoded file");
|
||||
}
|
||||
if (
|
||||
typeof source.mimeType !== "string" ||
|
||||
!/^audio\/[\w.+-]+(?:[ \t]*;[^\r\n]+)?$/i.test(source.mimeType)
|
||||
) {
|
||||
throw new Error("Audio must have an audio MIME type, such as audio/wav");
|
||||
}
|
||||
const bytes = Uint8Array.from(Buffer.from(source.base64, "base64"));
|
||||
const [mediaType, ...parameters] = source.mimeType.split(";");
|
||||
const type = [mediaType.trim().toLowerCase(), ...parameters].join(";");
|
||||
if (mediaType.trim().toLowerCase() === "audio/pcm") {
|
||||
throw new Error("Pass an audio file, such as WAV or MP3; raw PCM is not supported");
|
||||
}
|
||||
await engine.play(
|
||||
{
|
||||
type,
|
||||
size: bytes.byteLength,
|
||||
arrayBuffer: async () => bytes.buffer,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
|
||||
function harness() {
|
||||
const started: string[] = [];
|
||||
const finishes: (() => void)[] = [];
|
||||
const queue = createPlaybackQueue<string>((source, signal) => {
|
||||
started.push(source);
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
finishes.push(() => resolve(1));
|
||||
signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { once: true });
|
||||
});
|
||||
});
|
||||
return { queue, started, finishes };
|
||||
}
|
||||
|
||||
test("plays in order and resolves only after playback completes", async () => {
|
||||
const h = harness();
|
||||
const first = h.queue.play("first");
|
||||
const second = h.queue.play("second");
|
||||
expect(h.started).toEqual(["first"]);
|
||||
h.finishes[0]();
|
||||
await expect(first).resolves.toBe(1);
|
||||
expect(h.started).toEqual(["first", "second"]);
|
||||
h.finishes[1]();
|
||||
await expect(second).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test("cancels one owner's queued and active work without stopping another owner", async () => {
|
||||
const h = harness();
|
||||
const owner = new AbortController();
|
||||
const first = h.queue.play("first", owner.signal);
|
||||
const queued = h.queue.play("cancelled", owner.signal);
|
||||
const other = h.queue.play("other");
|
||||
const rejected = Promise.all([
|
||||
expect(first).rejects.toThrow("Playback stopped"),
|
||||
expect(queued).rejects.toThrow("Playback stopped"),
|
||||
]);
|
||||
owner.abort(new Error("unloaded"));
|
||||
await rejected;
|
||||
expect(h.started).toEqual(["first", "other"]);
|
||||
h.finishes[1]();
|
||||
await expect(other).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test("stop during loading cannot start cancelled audio or strand the next call", async () => {
|
||||
const h = harness();
|
||||
const first = h.queue.play("loading");
|
||||
const rejected = expect(first).rejects.toThrow("Playback stopped");
|
||||
h.queue.stop();
|
||||
await rejected;
|
||||
const second = h.queue.play("next");
|
||||
h.finishes[1]();
|
||||
await expect(second).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test("a failed file does not block the queue", async () => {
|
||||
const queue = createPlaybackQueue<string>(async (source) => {
|
||||
if (source === "invalid") throw new Error("Invalid audio");
|
||||
return 2;
|
||||
});
|
||||
const failed = queue.play("invalid");
|
||||
const next = queue.play("valid");
|
||||
await expect(failed).rejects.toThrow("Invalid audio");
|
||||
await expect(next).resolves.toBe(2);
|
||||
});
|
||||
|
||||
test("ending the voice waiting cue leaves a plugin's active audio playing", async () => {
|
||||
const { createVoiceRuntime } = await import("../voice/voice-runtime");
|
||||
let finishPlugin!: () => void;
|
||||
let cueQueued!: () => void;
|
||||
const queued = new Promise<void>((resolve) => {
|
||||
cueQueued = resolve;
|
||||
});
|
||||
const output = createPlaybackQueue<{ type: string }>(
|
||||
(_source, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
finishPlugin = () => resolve(1);
|
||||
signal.addEventListener("abort", () => reject(new Error("Playback stopped")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const engine = {
|
||||
initialize: async () => {},
|
||||
destroy: async () => {
|
||||
output.destroy();
|
||||
},
|
||||
startCapture: async () => {},
|
||||
stopCapture: async () => {},
|
||||
toggleMute: () => false,
|
||||
isMuted: () => false,
|
||||
play(source: { type: string }, signal?: AbortSignal) {
|
||||
if (source.type.startsWith("audio/pcm")) cueQueued();
|
||||
return output.play(source, signal);
|
||||
},
|
||||
stop: output.stop,
|
||||
clearQueue: output.clearQueue,
|
||||
isPlaying: output.isPlaying,
|
||||
};
|
||||
const runtime = createVoiceRuntime({
|
||||
engine,
|
||||
getServerInfo: () => ({
|
||||
serverId: "host",
|
||||
hostname: "host",
|
||||
version: "1.0.0",
|
||||
capabilities: {
|
||||
voice: { dictation: { enabled: true, reason: "" }, voice: { enabled: true, reason: "" } },
|
||||
},
|
||||
}),
|
||||
activateKeepAwake: async () => {},
|
||||
deactivateKeepAwake: async () => {},
|
||||
});
|
||||
runtime.registerSession({
|
||||
serverId: "host",
|
||||
setVoiceMode: async () => {},
|
||||
sendVoiceAudioChunk: async () => {},
|
||||
audioPlayed: async () => {},
|
||||
abortRequest: async () => {},
|
||||
setAssistantAudioPlaying: () => {},
|
||||
});
|
||||
try {
|
||||
await runtime.startVoice("host", "agent");
|
||||
const result = engine.play({ type: "audio/wav" }).then(
|
||||
() => "finished",
|
||||
() => "stopped",
|
||||
);
|
||||
runtime.onTurnEvent("host", "agent", "turn_started");
|
||||
await queued;
|
||||
runtime.onAssistantAudioStarted("host");
|
||||
finishPlugin();
|
||||
expect(await result).toBe("finished");
|
||||
} finally {
|
||||
await runtime.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/** One queue for voice and plugin audio, including cancellation while loading. */
|
||||
export function createPlaybackQueue<Source>(
|
||||
start: (source: Source, signal: AbortSignal) => Promise<number>,
|
||||
onIdle: () => void = () => {},
|
||||
) {
|
||||
interface Item {
|
||||
source: Source;
|
||||
controller: AbortController;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: unknown) => void;
|
||||
release: () => void;
|
||||
}
|
||||
const pending: Item[] = [];
|
||||
let active: Item | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
async function drain() {
|
||||
if (active) return;
|
||||
const item = pending.shift();
|
||||
if (!item) {
|
||||
onIdle();
|
||||
return;
|
||||
}
|
||||
active = item;
|
||||
try {
|
||||
if (item.controller.signal.aborted) throw new Error("Playback stopped");
|
||||
item.resolve(await start(item.source, item.controller.signal));
|
||||
} catch (error) {
|
||||
item.reject(error);
|
||||
} finally {
|
||||
item.release();
|
||||
active = null;
|
||||
void drain();
|
||||
}
|
||||
}
|
||||
|
||||
const stopped = () => new Error("Playback stopped");
|
||||
return {
|
||||
play(source: Source, signal?: AbortSignal): Promise<number> {
|
||||
if (destroyed) return Promise.reject(new Error("Audio engine destroyed"));
|
||||
if (signal?.aborted) return Promise.reject(new Error("Playback stopped"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const cancel = () => {
|
||||
controller.abort();
|
||||
const index = pending.indexOf(item);
|
||||
if (index !== -1) {
|
||||
pending.splice(index, 1);
|
||||
item.release();
|
||||
reject(new Error("Playback stopped"));
|
||||
}
|
||||
};
|
||||
const item: Item = {
|
||||
source,
|
||||
controller,
|
||||
resolve,
|
||||
reject,
|
||||
release: () => signal?.removeEventListener("abort", cancel),
|
||||
};
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
pending.push(item);
|
||||
void drain();
|
||||
});
|
||||
},
|
||||
stop() {
|
||||
active?.controller.abort();
|
||||
},
|
||||
clearQueue() {
|
||||
for (const item of pending.splice(0)) {
|
||||
item.release();
|
||||
item.reject(stopped());
|
||||
}
|
||||
},
|
||||
isPlaying: () => active !== null,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
this.clearQueue();
|
||||
this.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetFlatList,
|
||||
type BottomSheetFlatListMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -718,12 +714,6 @@ export function CommandCenter() {
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
const backdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
@@ -735,7 +725,7 @@ export function CommandCenter() {
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={backdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.sheetHandle}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -13,13 +13,11 @@ import {
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -75,11 +73,15 @@ export interface SheetHeader {
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeModalRoot: {
|
||||
nativeDialogSurface: {
|
||||
flex: 1,
|
||||
},
|
||||
nativeDialogBackground: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
@@ -471,7 +473,7 @@ export interface AdaptiveModalSheetProps {
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
|
||||
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
@@ -526,33 +528,26 @@ export function AdaptiveModalSheet({
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
// A newly opened sheet owns input. A keyboard belonging to the sheet below
|
||||
// would cover controls in the new sheet, which has no focused input yet.
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
isEnabled: isMobile || !isWeb,
|
||||
onClose,
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
const notifyNativeModalDismiss = useCallback(() => {
|
||||
if (nativeModalDismissNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeModalDismissNotifiedRef.current = true;
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [
|
||||
@@ -592,12 +587,6 @@ export function AdaptiveModalSheet({
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
nativeModalDismissNotifiedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile) return;
|
||||
if (visible) {
|
||||
@@ -615,12 +604,6 @@ export function AdaptiveModalSheet({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
@@ -658,7 +641,7 @@ export function AdaptiveModalSheet({
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
@@ -725,18 +708,28 @@ export function AdaptiveModalSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
onDismiss={notifyNativeModalDismiss}
|
||||
hardwareAccelerated
|
||||
// Both native presentations share Gorhom's app-wide stack. Independent RN Modals
|
||||
// present from their React ancestor's controller, so a root-owned sibling dialog
|
||||
// cannot present while that controller already has a dialog open on iOS.
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={NATIVE_DIALOG_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
handleComponent={null}
|
||||
backgroundStyle={styles.nativeDialogBackground}
|
||||
enablePanDownToClose={false}
|
||||
enableHandlePanningGesture={false}
|
||||
enableContentPanningGesture={false}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
{/* Android Modal opens a separate window outside the app's gesture root. */}
|
||||
<GestureHandlerRootView style={styles.nativeModalRoot}>
|
||||
{desktopContent}
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
<View style={styles.nativeDialogSurface}>{desktopContent}</View>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,11 +294,23 @@ export interface AddHostModalProps {
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
return (
|
||||
<AddHostModalContent
|
||||
key={String(visible)}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
||||
useHostMutations();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
const [linkPairing] = useState(() => beginLinkPairing());
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -313,18 +325,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setHost("");
|
||||
setPort("6767");
|
||||
setUseTls(false);
|
||||
setPassword("");
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
const connectIcon = useMemo(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
@@ -358,29 +358,25 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
|
||||
|
||||
// Each open mounts a fresh form (see AddHostModal), so closing needs no reset.
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSaveRelay = useCallback(
|
||||
async (relayUri: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
relayUri,
|
||||
password || undefined,
|
||||
);
|
||||
const result = await linkPairing.submit(relayUri, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
@@ -396,9 +392,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels.invalidConnection,
|
||||
handleClose,
|
||||
linkPairing,
|
||||
onSaved,
|
||||
password,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Github,
|
||||
HardDrive,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
applyAvailableAddProjectHosts,
|
||||
backAddProjectPage,
|
||||
@@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) {
|
||||
}
|
||||
|
||||
function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] {
|
||||
if (method === "github") return Github;
|
||||
if (method === "github") return GitHubIcon;
|
||||
if (method === "browse") return FolderOpen;
|
||||
if (method === "new-directory") return FolderPlus;
|
||||
return Search;
|
||||
@@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
|
||||
: repository.nameWithOwner,
|
||||
subtitle: repository.description,
|
||||
icon: Github,
|
||||
icon: GitHubIcon,
|
||||
testID: `add-project-flow-repository-${repository.id}`,
|
||||
select: () =>
|
||||
setState((current) => openGithubLocationPage(current, page.hostId, repository)),
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, Server } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
HostStatusDotSlot,
|
||||
type HostPickerHost,
|
||||
} from "@/components/hosts/host-picker";
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
@@ -16,7 +16,7 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export interface HostFilterProps {
|
||||
hosts: HostProfile[];
|
||||
hosts: HostPickerHost[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
/**
|
||||
@@ -29,9 +29,9 @@ export interface HostFilterProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The "All hosts / <host>" filter pill shared by the History and Schedules
|
||||
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
|
||||
* when only one host exists. Copies the History layout exactly.
|
||||
* The "All hosts / <host>" host pill shared by History, Schedules, Usage, the label manager and
|
||||
* plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without
|
||||
* `includeAllHost` it picks the one host a surface shows.
|
||||
*/
|
||||
export function HostFilter({
|
||||
hosts,
|
||||
|
||||
@@ -24,7 +24,7 @@ export {
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||
interface HostPickerHost {
|
||||
export interface HostPickerHost {
|
||||
serverId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { HostPicker } from "./host-picker";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface HostSwitcherHost {
|
||||
serverId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** A compact trigger showing the chosen host, opening the host picker to change it. */
|
||||
export function HostSwitcher({
|
||||
hosts,
|
||||
value,
|
||||
onSelect,
|
||||
title,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
hosts: HostSwitcherHost[];
|
||||
value: string;
|
||||
onSelect: (serverId: string) => void;
|
||||
/** The picker's title. */
|
||||
title: string;
|
||||
/** Prefixes the selected host's label: "Plugin host: Laptop". */
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<View | null>(null);
|
||||
const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value;
|
||||
const openPicker = useCallback(() => setOpen(true), []);
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
title={title}
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${accessibilityLabel}: ${selectedLabel}`}
|
||||
testID={testID}
|
||||
onPress={openPicker}
|
||||
style={styles.trigger}
|
||||
>
|
||||
<Text numberOfLines={1} style={styles.label}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
maxWidth: 180,
|
||||
minHeight: 32,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
@@ -44,6 +44,7 @@ import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
@@ -51,7 +52,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem } from "@/usage";
|
||||
import { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -88,9 +89,9 @@ interface SidebarSharedProps {
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
usage: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
@@ -195,9 +196,9 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
hosts: t("sidebar.actions.hosts"),
|
||||
importSession: t("importSession.title"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
usage: t(builtinSidebarNavLabelKey("usage")),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
@@ -282,6 +283,7 @@ function FooterIconButton({
|
||||
testID: string;
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSize?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
@@ -309,65 +311,7 @@ function FooterIconButton({
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function footerAddProjectButtonStyle({
|
||||
hovered,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
|
||||
}
|
||||
|
||||
function FooterAddProjectButton({
|
||||
onPress,
|
||||
label,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
shortcutKeys: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={footerAddProjectButtonStyle}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const isHovered = Boolean(hovered);
|
||||
return (
|
||||
<>
|
||||
<FolderPlus
|
||||
size={theme.iconSize.sm}
|
||||
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.footerAddProjectLabel,
|
||||
isHovered && styles.footerAddProjectLabelHovered,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<TooltipContent side="top" align="center" offset={8} testID={`${testID}-tooltip`}>
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -422,7 +366,8 @@ function SidebarHostPicker({
|
||||
testID="sidebar-hosts-trigger"
|
||||
label={label}
|
||||
icon={Server}
|
||||
iconSize={theme.iconSize.sm}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSize={theme.iconSize.md - 1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
@@ -447,7 +392,6 @@ function IconTooltipContent({
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
handleImportSession,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
@@ -456,14 +400,13 @@ function SidebarFooter({
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
handleOpenProject: () => void;
|
||||
handleImportSession: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
usage: string;
|
||||
};
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
@@ -471,30 +414,35 @@ function SidebarFooter({
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const openUsageScreen = useOpenUsageScreen();
|
||||
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<FooterAddProjectButton
|
||||
onPress={handleOpenProject}
|
||||
label={labels.addProject}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View style={styles.footerIconRow}>
|
||||
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={openUsageScreen}
|
||||
testID="sidebar-usage-icon"
|
||||
label={labels.usage}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleImportSession}
|
||||
testID="sidebar-import-session"
|
||||
label={labels.importSession}
|
||||
icon={Import}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
@@ -509,28 +457,37 @@ function SidebarFooter({
|
||||
);
|
||||
}
|
||||
|
||||
/** The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. */
|
||||
/**
|
||||
* The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. The
|
||||
* Usage item is left out while it has no summary to show.
|
||||
*/
|
||||
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const { items } = useSidebarNavItems("footer");
|
||||
const hasUsageSummary = useHasUsageSummary();
|
||||
const rowsRef = useRef<View | null>(null);
|
||||
const visibleItems = items.filter((item) => item.visible);
|
||||
const visibleItems = items.filter(
|
||||
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
|
||||
{visibleItems.map((item) =>
|
||||
item.kind === "plugin" ? (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="footer"
|
||||
fallbackAnchorRef={rowsRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
) : (
|
||||
<UsageSidebarItem key={item.key} />
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
<>
|
||||
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
|
||||
{visibleItems.map((item) =>
|
||||
item.kind === "plugin" ? (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="footer"
|
||||
fallbackAnchorRef={rowsRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
) : (
|
||||
<UsageSidebarItem key={item.key} />
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
<SidebarSeparator testID="sidebar-footer-separator" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -587,7 +544,11 @@ function MobileSidebar({
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
pointerEvents="box-none"
|
||||
style={styles.mobileCloseButtonRow}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
@@ -636,7 +597,6 @@ function MobileSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -814,7 +774,6 @@ function DesktopSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -913,7 +872,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
alignItems: "flex-end",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
@@ -961,46 +919,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sidebarFooter: {
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
footerContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// Every footer row is 28px tall with `spacing[1]` between rows, the same as the gap above.
|
||||
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
},
|
||||
// Pushes Help and Settings to the end of the icon line.
|
||||
footerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
// Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows.
|
||||
footerRows: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// The leading glyph sits on the footer icons' rail: a 16px icon centered in a 28px button
|
||||
// starts 6px in, `spacing[1.5]`.
|
||||
footerAddProjectButton: {
|
||||
minWidth: 0,
|
||||
minHeight: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
footerAddProjectButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
footerAddProjectLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerAddProjectLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
|
||||
@@ -113,6 +113,85 @@ describe("splitHtmlishMarkdown", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a linked picture inside a heading without exposing its HTML", () => {
|
||||
const source = [
|
||||
'<h2><a href="https://example.com/retrigger?id=73851569"><picture>',
|
||||
'<source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.svg">',
|
||||
'<source media="(prefers-color-scheme: light)" srcset="https://example.com/light.svg">',
|
||||
'<img alt="Retrigger" src="https://example.com/default.svg" width="85" height="24">',
|
||||
"</picture></a></h2>",
|
||||
"",
|
||||
"Confidence Score: 5/5",
|
||||
"",
|
||||
"<details><summary>Summary</summary>No outstanding findings.</details>",
|
||||
].join("\n");
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Retrigger",
|
||||
src: "https://example.com/default.svg",
|
||||
href: "https://example.com/retrigger?id=73851569",
|
||||
width: 85,
|
||||
height: 24,
|
||||
},
|
||||
{ kind: "markdown", text: "\n\nConfidence Score: 5/5\n\n" },
|
||||
{ kind: "details", summary: "Summary", body: "No outstanding findings." },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", ""],
|
||||
["<h3>", "</h3>"],
|
||||
['<a href="javascript:alert(1)">', "</a>"],
|
||||
])("uses the picture fallback without activating unsafe links (%s)", (wrapper, closing) => {
|
||||
const picture =
|
||||
'<picture><source srcset="https://example.com/alternate.svg"/><img src="https://example.com/default.svg" alt="Image"></picture>';
|
||||
|
||||
expect(splitHtmlishMarkdown(`${wrapper}${picture}${closing}`)).toEqual([
|
||||
{ kind: "inlineImage", src: "https://example.com/default.svg", alt: "Image" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps picture examples literal inside inline and fenced code", () => {
|
||||
const picture =
|
||||
'<picture><source srcset="https://example.com/alternate.svg"><img src="https://example.com/default.svg"></picture>';
|
||||
const source = `Use \`${picture}\`\n\n\`\`\`html\n${picture}\n\`\`\``;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it.each(["p", "div"])("renders a linked picture inside %s", (wrapper) => {
|
||||
const source = `<${wrapper}><a href="https://example.com/page"><picture><source srcset="https://example.com/dark.svg"><img src="https://example.com/default.svg" alt="Image"></picture></a></${wrapper}>`;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
src: "https://example.com/default.svg",
|
||||
alt: "Image",
|
||||
href: "https://example.com/page",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["h2", "p", "div"])("keeps text after an image-only %s on a separate row", (wrapper) => {
|
||||
const source = `<${wrapper}><img src="https://example.com/image.svg" alt="Image"></${wrapper}>Next section`;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "inlineImage", src: "https://example.com/image.svg", alt: "Image" },
|
||||
{ kind: "markdown", text: "Next section" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles deeply nested image wrappers without exhausting the stack", () => {
|
||||
const source = `${"<div>".repeat(10000)}<a href="https://example.com/page"><picture><img src="https://example.com/image.svg" alt="Image"></picture></a>${"</div>".repeat(10000)}`;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
src: "https://example.com/image.svg",
|
||||
alt: "Image",
|
||||
href: "https://example.com/page",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves inline image parts inside details bodies", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
|
||||
@@ -29,7 +29,7 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
|
||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
b: ["**", "**"],
|
||||
del: ["~~", "~~"],
|
||||
@@ -140,25 +140,73 @@ function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImagePars
|
||||
return image ? { part: image, end: start + 1 } : null;
|
||||
}
|
||||
|
||||
if (token.name !== "a") {
|
||||
if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeIndex = findMatchingClose(tokens, start, "a");
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
return parseWrappedImageAt(tokens, start);
|
||||
}
|
||||
|
||||
const image = getSingleImageChild(tokens.slice(start + 1, closeIndex));
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
// Walk an image-only wrapper chain once. Recursing over copied child slices
|
||||
// makes deeply nested review HTML quadratic and can exhaust the JS stack.
|
||||
const wrappers: string[] = [];
|
||||
let image: MarkdownInlineImagePart | null = null;
|
||||
let href: string | undefined;
|
||||
let hasLink = false;
|
||||
|
||||
const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href));
|
||||
return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null;
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
const child = tokens[index];
|
||||
if (child.kind === "comment" || isWhitespaceText(child)) {
|
||||
continue;
|
||||
}
|
||||
if (child.kind !== "tag") {
|
||||
return null;
|
||||
}
|
||||
if (child.closing) {
|
||||
if (!image || wrappers.pop() !== child.name) {
|
||||
return null;
|
||||
}
|
||||
if (wrappers.length === 0) {
|
||||
return { part: image, end: index + 1 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Pictures use their img fallback, not responsive source selection.
|
||||
if (child.name === "source" && wrappers.at(-1) === "picture") {
|
||||
continue;
|
||||
}
|
||||
if (image) {
|
||||
return null;
|
||||
}
|
||||
if (child.name === "img") {
|
||||
image = imageTokenToInlineImage(child, href);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) {
|
||||
return null;
|
||||
}
|
||||
if (child.name === "a" && !hasLink) {
|
||||
href = safeHref(child.attributes.href);
|
||||
hasLink = true;
|
||||
}
|
||||
wrappers.push(child.name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isImageBlockWrapper(token: HtmlToken | undefined): boolean {
|
||||
return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div");
|
||||
}
|
||||
|
||||
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
|
||||
if (isImageBlockWrapper(tokens[start])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previous = tokens[start - 1];
|
||||
// At line start if nothing precedes this image, or the preceding token ends with only
|
||||
// whitespace since the last newline (covers a bare space between two images on the same line).
|
||||
@@ -191,6 +239,9 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
|
||||
}
|
||||
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
|
||||
if (token.kind === "tag") {
|
||||
if (isImageBlockWrapper(token)) {
|
||||
return false;
|
||||
}
|
||||
const imageResult = parseInlineImageAt(tokens, cursor);
|
||||
if (imageResult) {
|
||||
cursor = imageResult.end;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface InlineImageExplicitDimensions {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const INLINE_IMAGE_FALLBACK_SIZE = 16;
|
||||
const INLINE_IMAGE_MAX_WIDTH = 240;
|
||||
const INLINE_IMAGE_MAX_HEIGHT = 160;
|
||||
|
||||
@@ -59,5 +58,5 @@ function resolveInlineImageDimensions(input: {
|
||||
return { width: input.explicit.height, height: input.explicit.height };
|
||||
}
|
||||
|
||||
return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE };
|
||||
return input.natural ?? { width: INLINE_IMAGE_MAX_WIDTH, height: INLINE_IMAGE_MAX_HEIGHT };
|
||||
}
|
||||
|
||||
@@ -63,10 +63,10 @@ describe("resolveInlineImageSize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a generic small fallback when no dimensions are known", () => {
|
||||
it("reserves a capped 3:2 placeholder when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 16,
|
||||
height: 16,
|
||||
width: 240,
|
||||
height: 160,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
@@ -201,11 +205,16 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const [natural, setNatural] = useState<InlineImageDimensions | null>(null);
|
||||
const cached = useMemo(() => getAssistantImageMetadata({ source: part.src }), [part.src]);
|
||||
const [resolved, setResolved] = useState<{
|
||||
source: string;
|
||||
dimensions: InlineImageDimensions;
|
||||
} | null>(null);
|
||||
const natural = resolved?.source === part.src ? resolved.dimensions : cached;
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (part.width && part.height) {
|
||||
if ((part.width && part.height) || cached) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,8 +222,9 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
setAssistantImageMetadata({ source: part.src }, { width, height });
|
||||
if (!cancelled) {
|
||||
setNatural({ width, height });
|
||||
setResolved({ source: part.src, dimensions: { width, height } });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -227,7 +237,7 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [part.height, part.src, part.width]);
|
||||
}, [cached, part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
import {
|
||||
@@ -814,8 +815,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
function AssistantMarkdownImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
@@ -860,11 +859,9 @@ function AssistantMarkdownImage({
|
||||
[containerStyle],
|
||||
);
|
||||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||
if (aspectRatio) {
|
||||
return { aspectRatio };
|
||||
}
|
||||
return { height: ASSISTANT_IMAGE_MIN_HEIGHT };
|
||||
}, [aspectRatio]);
|
||||
if (image.status === "failed") return { height: 160 };
|
||||
return { aspectRatio: aspectRatio ?? ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO };
|
||||
}, [aspectRatio, image.status]);
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
@@ -882,10 +879,10 @@ function AssistantMarkdownImage({
|
||||
() => [
|
||||
assistantMessageStylesheet.imageFrame,
|
||||
containerStyle,
|
||||
{ height: ASSISTANT_IMAGE_MIN_HEIGHT },
|
||||
imageSizeStyle,
|
||||
assistantMessageStylesheet.imageState,
|
||||
],
|
||||
[containerStyle],
|
||||
[containerStyle, imageSizeStyle],
|
||||
);
|
||||
|
||||
if (image.status === "failed") {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
@@ -62,8 +62,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
initialUrl?: string;
|
||||
initialPasswordRequired?: boolean;
|
||||
/** Continues a confirmed pairing whose host asked for a password. */
|
||||
passwordRequired?: PasswordRequiredPairing;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
@@ -76,18 +76,16 @@ export interface PairLinkModalProps {
|
||||
|
||||
export function PairLinkModal({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
return (
|
||||
<PairLinkModalContent
|
||||
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
|
||||
key={`${visible}:${passwordRequired?.link ?? ""}`}
|
||||
visible={visible}
|
||||
initialUrl={initialUrl}
|
||||
initialPasswordRequired={initialPasswordRequired}
|
||||
passwordRequired={passwordRequired}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
@@ -97,8 +95,7 @@ export function PairLinkModal({
|
||||
|
||||
function PairLinkModalContent({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
@@ -106,7 +103,9 @@ function PairLinkModalContent({
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
const [pairing] = useState(() => passwordRequired?.pairing ?? beginLinkPairing());
|
||||
const initialUrl = passwordRequired?.link;
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const offerUrlRef = useRef(initialUrl ?? "");
|
||||
@@ -115,7 +114,7 @@ function PairLinkModalContent({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
|
||||
const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined);
|
||||
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
@@ -162,10 +161,9 @@ function PairLinkModalContent({
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
raw,
|
||||
password || undefined,
|
||||
);
|
||||
const result = await pairing.submit(raw, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
@@ -184,16 +182,7 @@ function PairLinkModalContent({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
daemons,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
password,
|
||||
t,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
[daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing],
|
||||
);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
interface SettingsCollapsibleRowProps {
|
||||
label: string;
|
||||
defaultExpanded?: boolean;
|
||||
testID?: string;
|
||||
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settings row that folds a group of rows under it. Collapsed, it is one row: the label and a
|
||||
* trailing chevron that points right, and turns down once expanded. Sits inside a `SettingsCard` like any
|
||||
* other row.
|
||||
*/
|
||||
export function SettingsCollapsibleRow({
|
||||
label,
|
||||
defaultExpanded = false,
|
||||
testID,
|
||||
children,
|
||||
}: SettingsCollapsibleRowProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
|
||||
const webExpandedProps = useMemo(
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const headerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
hovered && styles.headerHovered,
|
||||
pressed && styles.headerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
[expanded],
|
||||
);
|
||||
|
||||
return (
|
||||
<View testID={testID}>
|
||||
<Pressable
|
||||
{...webExpandedProps}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={toggle}
|
||||
style={headerStyle}
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
</View>
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
{expanded
|
||||
? Children.toArray(children).map((child, index) => (
|
||||
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
|
||||
{child}
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
|
||||
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
|
||||
chevron: { marginRight: -theme.spacing[1] },
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
@@ -26,6 +26,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
export { SettingsGroup } from "./headings/settings-group";
|
||||
export { SettingsSection } from "./headings/settings-section";
|
||||
export { SettingsCollapsibleRow } from "./collapsible-row";
|
||||
|
||||
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
|
||||
hint?: ReactNode;
|
||||
|
||||
@@ -89,7 +89,7 @@ export function SidebarHeaderRow({
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
accessibilityState={isActive ? SELECTED_STATE : undefined}
|
||||
aria-selected={isActive}
|
||||
style={variant === "inline" ? styles.buttonInline : styles.button}
|
||||
style={styles.button}
|
||||
>
|
||||
{ThemedIcon ? (
|
||||
<ThemedIcon
|
||||
@@ -158,17 +158,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
// Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button.
|
||||
buttonInline: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
},
|
||||
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
|
||||
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
|
||||
label: {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
* padded container so the line runs the full sidebar width — matching the
|
||||
* separator beneath the workspace sidebar's "New agent" header.
|
||||
*/
|
||||
export function SidebarSeparator() {
|
||||
return <View style={styles.line} />;
|
||||
export function SidebarSeparator({ testID }: { testID?: string } = {}) {
|
||||
return <View style={styles.line} testID={testID} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -82,6 +82,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
import {
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
selectExplorerSidebarPaneId,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -351,8 +352,8 @@ export function SplitContainer({
|
||||
} | null>(null);
|
||||
const maximizedPaneId =
|
||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||
selectExplorerSidebarPaneId(state, workspaceKey),
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -397,18 +398,6 @@ export function SplitContainer({
|
||||
},
|
||||
[workspaceKey],
|
||||
);
|
||||
const handleCreateExplorerTab = useCallback(
|
||||
() => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }),
|
||||
[explorerSidebarPaneId, onCreateNewTab],
|
||||
);
|
||||
const handleMoveExplorerTabToMain = useCallback(
|
||||
(tabId: string) => {
|
||||
if (layout.focusedPaneId) {
|
||||
onMoveTabToPane(tabId, layout.focusedPaneId);
|
||||
}
|
||||
},
|
||||
[layout.focusedPaneId, onMoveTabToPane],
|
||||
);
|
||||
const splitRoot = useMemo(
|
||||
() =>
|
||||
resolveSplitContainerRoot({
|
||||
@@ -725,8 +714,19 @@ export function SplitContainer({
|
||||
closingTabIds={closingTabIds}
|
||||
onSelectTab={onSelectTabInPane}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={handleCreateExplorerTab}
|
||||
onMoveTabToMain={handleMoveExplorerTabToMain}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
@@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
setSheetData(null);
|
||||
}, [handleSheetDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ openToolCall, closeToolCall }),
|
||||
[openToolCall, closeToolCall],
|
||||
@@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleToolCallSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.5}
|
||||
enablePanDownToClose
|
||||
>
|
||||
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user