mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
34
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 |
@@ -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
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
@@ -91,6 +107,12 @@
|
||||
- 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
|
||||
|
||||
+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.
|
||||
|
||||
+25
-15
@@ -115,27 +115,37 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
||||
[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
|
||||
|
||||
|
||||
+4
-9
@@ -187,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-wS6J8HSLqxaFmdX849bowzUkTnPueRM61rcdcvFEUuQ=
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
|
||||
Generated
+58
-41
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.2",
|
||||
"@getpaseo/protocol": "0.11.0-beta.2",
|
||||
"@getpaseo/server": "0.11.0-beta.2",
|
||||
"@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.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.2",
|
||||
"@getpaseo/relay": "0.11.0-beta.2",
|
||||
"@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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.2",
|
||||
"@getpaseo/protocol": "0.11.0-beta.2",
|
||||
"@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.11.0-beta.2",
|
||||
"@getpaseo/protocol": "0.11.0-beta.2",
|
||||
"@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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"@getpaseo/highlight": "0.11.0-beta.2",
|
||||
"@getpaseo/plugin": "0.11.0-beta.2",
|
||||
"@getpaseo/protocol": "0.11.0-beta.2",
|
||||
"@getpaseo/relay": "0.11.0-beta.2",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/highlight": "0.11.0-beta.3",
|
||||
"@getpaseo/plugin": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@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.11.0-beta.2",
|
||||
"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.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.2",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
@@ -12,7 +13,49 @@ import {
|
||||
type UsageListRequest,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { expectPinnedUsage, usageItem } from "../support/helpers/usage-sidebar-item";
|
||||
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);
|
||||
@@ -73,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" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -191,6 +237,8 @@ test.describe("usage screen", () => {
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -244,3 +292,195 @@ test.describe("usage screen", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ function forcedRefreshCount(usage: UsageReportsFixture): number {
|
||||
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`,
|
||||
@@ -27,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,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();
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -114,6 +114,11 @@ test.describe("Usage item", () => {
|
||||
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 () => {
|
||||
@@ -122,13 +127,17 @@ test.describe("Usage item", () => {
|
||||
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await 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: "Usage options", 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(".."),
|
||||
@@ -176,22 +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.
|
||||
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
|
||||
// The options open as a sheet of their own over the usage sheet.
|
||||
// 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(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "compact-options-sheet");
|
||||
// Tap the backdrop above both sheets; only the options sheet closes.
|
||||
await page.mouse.click(COMPACT.width / 2, 40);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await 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");
|
||||
@@ -208,11 +221,11 @@ test.describe("Usage item", () => {
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "usage-options-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
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 () => {
|
||||
@@ -226,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 default windows, 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 expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await 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 () => {
|
||||
@@ -262,7 +277,10 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
|
||||
fetchedAt: new Date().toISOString(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { status: "unavailable", windows: [] },
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -280,7 +298,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
test("the Usage options show and hide the sidebar Usage item", async ({ 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);
|
||||
@@ -289,7 +307,7 @@ test("the Usage options show and hide the sidebar Usage item", async ({ page })
|
||||
await expectOnUsageScreen(page);
|
||||
|
||||
await openUsageOptions(page);
|
||||
const toggle = page.getByTestId("usage-show-in-sidebar");
|
||||
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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -225,6 +225,9 @@ const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
// 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.
|
||||
@@ -240,10 +243,12 @@ export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
expect(box.width).toBe(first!.width);
|
||||
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));
|
||||
expect(gaps).toEqual([0, 0, expect.any(Number), 0]);
|
||||
for (const index of [0, 1, 3]) {
|
||||
expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
|
||||
}
|
||||
expect(gaps[2]).toBeGreaterThan(first!.width);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,13 @@ export async function installUsageReportsFixture(
|
||||
providerId: entry.sourceId,
|
||||
displayName: entry.sourceLabel,
|
||||
fetchedAt: entry.fetchedAt,
|
||||
...entry.report,
|
||||
planLabel: entry.report.planLabel ?? null,
|
||||
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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -36,8 +36,7 @@ import { z } from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({
|
||||
id: "tall-usage", label: "Scrolling account", input: z.object({}),
|
||||
discover: async () => [{}],
|
||||
identify: async () => ({ key: "scrolling-account" }),
|
||||
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,
|
||||
})) }),
|
||||
@@ -176,21 +175,24 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
/** The usage title row's options menu: Refresh and Used/Remaining. */
|
||||
/** Expand the inline Settings row when its controls are folded. */
|
||||
export async function openUsageOptions(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="usage-options-menu"]:visible').first().click();
|
||||
await expect(page.getByTestId("usage-display-used")).toBeVisible();
|
||||
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 page.getByTestId(`usage-display-${displayAs}`).click();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
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 openUsageOptions(page);
|
||||
await page.getByRole("menuitem", { name: "Refresh", exact: true }).click();
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.2",
|
||||
"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";
|
||||
@@ -113,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";
|
||||
@@ -972,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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1921,10 +1921,15 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
paddingVertical: {
|
||||
paddingTop: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
// The button row bleeds 6px horizontally, so match its corner inset at the bottom.
|
||||
paddingBottom: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[3],
|
||||
},
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import type { AudioPlaybackSource } from "@/audio";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
toDaemonServerInfo,
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from "react";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createAudioEngine } from "@/voice/audio-engine";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import { createAudioEngine } from "@/audio";
|
||||
import type { AudioEngine } from "@/audio";
|
||||
import {
|
||||
createVoiceRuntime,
|
||||
type VoiceRuntime,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { useState } from "react";
|
||||
|
||||
import { createAudioEngine } from "@/voice/audio-engine";
|
||||
import { createAudioEngine } from "@/audio";
|
||||
|
||||
import type {
|
||||
DictationAudioSource,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resampleToPcm16 } from "@/audio/pcm";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parsePcm16Wav } from "@/utils/pcm16-wav";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
@@ -17,41 +18,6 @@ const getAudioContextCtor = (): typeof AudioContext | null => {
|
||||
return ctor ?? null;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
||||
if (a.length === 0) {
|
||||
return b;
|
||||
|
||||
@@ -263,10 +263,10 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads usage preferences with pins in order and defaults a fresh device to none", async () => {
|
||||
it("loads legacy usage pins in order and defaults a fresh device to source defaults", async () => {
|
||||
expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({
|
||||
displayAs: "used",
|
||||
pinned: [],
|
||||
pins: null,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
@@ -287,7 +287,7 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({
|
||||
displayAs: "remaining",
|
||||
pinned: [
|
||||
pins: [
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
],
|
||||
@@ -295,6 +295,21 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads an explicitly empty usage selection without restoring defaults", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
usage: { displayAs: "remaining", pins: [], serverId: "server" },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({
|
||||
displayAs: "remaining",
|
||||
pins: [],
|
||||
serverId: "server",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps valid usage preferences when one field is malformed", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
@@ -310,7 +325,7 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({
|
||||
displayAs: "used",
|
||||
pinned: [{ sourceId: "claude", windowId: "weekly" }],
|
||||
pins: [{ sourceId: "claude", windowId: "weekly" }],
|
||||
serverId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,7 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement {
|
||||
serverId,
|
||||
purpose: host === "explorer" ? "supporting" : "primary",
|
||||
host,
|
||||
surface: "panel",
|
||||
});
|
||||
const itemsById = useMemo(
|
||||
() => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { panelResourceKey, panelSupportsHost } from "@/panels/panel-manifest";
|
||||
import {
|
||||
getPanelManifest,
|
||||
panelCanLaunchInPane,
|
||||
panelResourceKey,
|
||||
panelSupportsHost,
|
||||
} from "@/panels/panel-manifest";
|
||||
|
||||
describe("panel manifest", () => {
|
||||
it("answers host support without React panel registration", () => {
|
||||
@@ -13,6 +18,23 @@ describe("panel manifest", () => {
|
||||
expect(panelSupportsHost("setup", "explorer")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets navigation tabs hide their close button without losing their close action", () => {
|
||||
expect(getPanelManifest("files").showCloseButton).toBe(false);
|
||||
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
|
||||
expect(getPanelManifest("agent").showCloseButton).toBe(true);
|
||||
expect(getPanelManifest("terminal").showCloseButton).toBe(true);
|
||||
expect(getPanelManifest("file").showCloseButton).toBe(true);
|
||||
expect(getPanelManifest("working_diff").showCloseButton).toBe(true);
|
||||
});
|
||||
|
||||
it("hides a singleton launch only when it already exists in the destination pane", () => {
|
||||
expect(panelCanLaunchInPane("files", ["files"])).toBe(false);
|
||||
expect(panelCanLaunchInPane("changes_tree", ["changes_tree"])).toBe(false);
|
||||
expect(panelCanLaunchInPane("files", ["changes_tree"])).toBe(true);
|
||||
expect(panelCanLaunchInPane("files", [])).toBe(true);
|
||||
expect(panelCanLaunchInPane("terminal", ["terminal"])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps durable resource identity separate from transient target input", () => {
|
||||
expect(
|
||||
panelResourceKey({ kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 }),
|
||||
|
||||
@@ -5,6 +5,8 @@ export type PaneHost = "main" | "explorer";
|
||||
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
supportedHosts: readonly PaneHost[];
|
||||
showCloseButton: boolean;
|
||||
singleton: boolean;
|
||||
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
||||
}
|
||||
|
||||
@@ -16,62 +18,86 @@ const manifests = {
|
||||
new_tab: {
|
||||
kind: "new_tab",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: () => "new_tab",
|
||||
},
|
||||
draft: {
|
||||
kind: "draft",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.draftId,
|
||||
},
|
||||
agent: {
|
||||
kind: "agent",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.agentId,
|
||||
},
|
||||
provider_subagent: {
|
||||
kind: "provider_subagent",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||
},
|
||||
terminal: {
|
||||
kind: "terminal",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.terminalId,
|
||||
},
|
||||
browser: {
|
||||
kind: "browser",
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.browserId,
|
||||
},
|
||||
changes_tree: {
|
||||
kind: "changes_tree",
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
resourceKey: () => "changes_tree",
|
||||
},
|
||||
files: {
|
||||
kind: "files",
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
resourceKey: () => "files",
|
||||
},
|
||||
pull_request: {
|
||||
kind: "pull_request",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: () => "pull_request",
|
||||
},
|
||||
file: {
|
||||
kind: "file",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.path,
|
||||
},
|
||||
working_diff: {
|
||||
kind: "working_diff",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: () => "working_diff",
|
||||
},
|
||||
plugin: {
|
||||
kind: "plugin",
|
||||
// Plugin targets are narrowed by the target-aware plugin panel capability resolver.
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) =>
|
||||
target.context === "agent"
|
||||
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
||||
@@ -80,11 +106,15 @@ const manifests = {
|
||||
setup: {
|
||||
kind: "setup",
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.workspaceId,
|
||||
},
|
||||
commit_diff: {
|
||||
kind: "commit_diff",
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
resourceKey: (target) => target.sha,
|
||||
},
|
||||
} satisfies PanelManifestByKind;
|
||||
@@ -97,6 +127,13 @@ export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHo
|
||||
return getPanelManifest(kind).supportedHosts.includes(host);
|
||||
}
|
||||
|
||||
export function panelCanLaunchInPane(
|
||||
kind: WorkspaceTabTarget["kind"],
|
||||
panePanelKinds: readonly WorkspaceTabTarget["kind"][],
|
||||
): boolean {
|
||||
return !getPanelManifest(kind).singleton || !panePanelKinds.includes(kind);
|
||||
}
|
||||
|
||||
export function panelResourceKey(target: WorkspaceTabTarget): string {
|
||||
const manifest = getPanelManifest(target.kind);
|
||||
return `${target.kind}:${manifest.resourceKey(target as never)}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { pluginSettingsKey } from "./settings/use-settings";
|
||||
import { useEffect } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -12,13 +13,14 @@ export function PluginCatalogSync({
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
}) {
|
||||
const audio = useVoiceAudioEngineOptional();
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "plugins");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
if (!supported) {
|
||||
if (!supported || !audio) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
@@ -35,6 +37,7 @@ export function PluginCatalogSync({
|
||||
pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
audio,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
@@ -79,7 +82,7 @@ export function PluginCatalogSync({
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}, [client, connected, serverId, supported]);
|
||||
}, [audio, client, connected, serverId, supported]);
|
||||
|
||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
@@ -12,7 +13,10 @@ import { createPluginNavigation } from "./navigation";
|
||||
import { pluginButtonStore } from "./buttons";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export function createPluginClientRuntime(installation: InstalledPlugin): PluginClientRuntime {
|
||||
export function createPluginClientRuntime(
|
||||
installation: InstalledPlugin,
|
||||
audio: Pick<AudioEngine, "play">,
|
||||
): PluginClientRuntime {
|
||||
const state = createPluginClientStateSource(installation.serverId);
|
||||
const capabilities = createPluginCapabilities(
|
||||
installation,
|
||||
@@ -20,6 +24,7 @@ export function createPluginClientRuntime(installation: InstalledPlugin): Plugin
|
||||
);
|
||||
return {
|
||||
...capabilities,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
addComposerPill(contribution) {
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
|
||||
@@ -91,6 +91,7 @@ function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle
|
||||
|
||||
export type PluginClientRuntime = Pick<
|
||||
PluginClientContext,
|
||||
| "playAudio"
|
||||
| "paseo"
|
||||
| "rpc"
|
||||
| "openSettings"
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PaseoApi } from "@getpaseo/client";
|
||||
import { expect, test } from "vitest";
|
||||
import { PluginRegistry } from "./registry";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
|
||||
/** A host client that records open event observations by their first event name. */
|
||||
function observingClient() {
|
||||
const open = new Set<string>();
|
||||
@@ -47,6 +49,7 @@ function registry() {
|
||||
openScreen: () => {},
|
||||
openSurface: () => {},
|
||||
openSettings: () => {},
|
||||
playAudio: async () => {},
|
||||
openPanel: () => {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
@@ -67,7 +70,7 @@ function catalog(id: string, body = "return function() {};") {
|
||||
|
||||
test("setup and every surface share the installation's one Paseo client", () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client });
|
||||
h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio });
|
||||
|
||||
const [installation] = h.plugins.getSnapshot();
|
||||
expect(h.setupClients.get("deploys")).toBe(installation.paseo);
|
||||
@@ -76,11 +79,14 @@ test("setup and every surface share the installation's one Paseo client", () =>
|
||||
|
||||
test("teardown ends every subscription the plugin still holds", async () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { client: h.client });
|
||||
h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], {
|
||||
client: h.client,
|
||||
audio,
|
||||
});
|
||||
expect(h.open).toEqual(new Set(["disabled", "kept"]));
|
||||
|
||||
// Disabling one plugin removes it from the catalog.
|
||||
h.plugins.installCatalog("host", [catalog("kept")], { client: h.client });
|
||||
h.plugins.installCatalog("host", [catalog("kept")], { client: h.client, audio });
|
||||
await expect.poll(() => [...h.open]).toEqual(["kept"]);
|
||||
|
||||
h.plugins.removeHost("host");
|
||||
@@ -89,11 +95,12 @@ test("teardown ends every subscription the plugin still holds", async () => {
|
||||
|
||||
test("reloading a plugin ends the old installation's subscriptions", async () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client });
|
||||
h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, audio });
|
||||
const [first] = h.plugins.getSnapshot();
|
||||
|
||||
h.plugins.installCatalog("host", [catalog("reloaded")], {
|
||||
client: h.client,
|
||||
audio,
|
||||
replacePluginId: "reloaded",
|
||||
});
|
||||
|
||||
@@ -109,6 +116,7 @@ test("failed plugin initialization ends the subscriptions it opened", async () =
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], {
|
||||
client: h.client,
|
||||
audio,
|
||||
});
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
expect(h.plugins.getSnapshot()).toEqual([]);
|
||||
@@ -118,8 +126,8 @@ test("unloading ends the subscriptions even when plugin cleanup throws, preservi
|
||||
const h = registry();
|
||||
const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };');
|
||||
const surviving = catalog("surviving", "return function() {};");
|
||||
h.plugins.installCatalog("host", [failing, surviving], { client: h.client });
|
||||
h.plugins.installCatalog("host", [surviving], { client: h.client });
|
||||
h.plugins.installCatalog("host", [failing, surviving], { client: h.client, audio });
|
||||
h.plugins.installCatalog("host", [surviving], { client: h.client, audio });
|
||||
await expect.poll(() => [...h.open]).toEqual(["surviving"]);
|
||||
expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]);
|
||||
h.plugins.removeHost("host");
|
||||
@@ -131,7 +139,7 @@ test("an invalid async client entry is disposed and its rejected continuation is
|
||||
h.plugins.installCatalog(
|
||||
"host",
|
||||
[catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')],
|
||||
{ client: h.client },
|
||||
{ client: h.client, audio },
|
||||
);
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -3,6 +3,8 @@ import { afterEach, expect, it } from "vitest";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { PluginRegistry } from "./registry";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
|
||||
const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" });
|
||||
const registries: PluginRegistry[] = [];
|
||||
function registry(version: string) {
|
||||
@@ -31,6 +33,7 @@ function registry(version: string) {
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
async playAudio() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
@@ -57,7 +60,7 @@ it("checks the app version before creating a runtime or evaluating plugin code",
|
||||
clientBundle: "throw new Error('executed')",
|
||||
},
|
||||
],
|
||||
{ client },
|
||||
{ client, audio },
|
||||
);
|
||||
expect(starts()).toBe(0);
|
||||
expect(result.getSnapshot()).toEqual([]);
|
||||
@@ -68,7 +71,7 @@ it("checks the app version before creating a runtime or evaluating plugin code",
|
||||
|
||||
it("rejects catalogs without requirements from pre-0.8 daemons", () => {
|
||||
const { result, starts } = registry("0.8.0");
|
||||
result.installCatalog("host", [{ id: "example", clientBundle }], { client });
|
||||
result.installCatalog("host", [{ id: "example", clientBundle }], { client, audio });
|
||||
expect(starts()).toBe(0);
|
||||
expect(result.getEvaluationError("host", "example")).toContain(
|
||||
"https://paseo.sh/docs/plugins/migration",
|
||||
@@ -80,6 +83,7 @@ it("unloads on a requirement-only edit and recovers after correction", () => {
|
||||
const install = (paseo: string) =>
|
||||
result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], {
|
||||
client,
|
||||
audio,
|
||||
});
|
||||
install("^0.8.0");
|
||||
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { pluginRegistry as registry } from "./registry";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
|
||||
vi.mock("./navigation", () => ({
|
||||
createPluginNavigation: () => ({}),
|
||||
}));
|
||||
@@ -30,7 +32,7 @@ const pluginRegistry = {
|
||||
return registry.installCatalog(
|
||||
serverId,
|
||||
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
||||
{ ...options, client: daemonClient },
|
||||
{ ...options, client: daemonClient, audio },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AudioEngine } from "@/audio";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
@@ -41,6 +42,7 @@ export class PluginRegistry {
|
||||
options: {
|
||||
replacePluginId?: string;
|
||||
client: DaemonClient;
|
||||
audio: Pick<AudioEngine, "play">;
|
||||
},
|
||||
): boolean {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
@@ -99,7 +101,7 @@ export class PluginRegistry {
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
const runtime = this.dependencies.createRuntime(installation);
|
||||
const runtime = this.dependencies.createRuntime(installation, options.audio);
|
||||
const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () =>
|
||||
this.publish(),
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ vi.mock("../icons", () => ({
|
||||
import { pluginRegistry } from "../registry";
|
||||
import { PluginTimelineItemView } from "./view";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
|
||||
const bundle = `(function(require) {
|
||||
const React = require("react");
|
||||
return { default: function(plugin) {
|
||||
@@ -122,6 +124,7 @@ describe("PluginTimelineItemView", () => {
|
||||
[{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }],
|
||||
{
|
||||
client: daemonClient,
|
||||
audio,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -151,6 +154,7 @@ describe("PluginTimelineItemView", () => {
|
||||
],
|
||||
{
|
||||
client: daemonClient,
|
||||
audio,
|
||||
},
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
@@ -188,6 +192,7 @@ describe("PluginTimelineItemView", () => {
|
||||
],
|
||||
{
|
||||
client: daemonClient,
|
||||
audio,
|
||||
},
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
@@ -310,7 +315,7 @@ it("a renderer releases its observation on the plugin's client when it crashes,
|
||||
clientBundle: liveBundle,
|
||||
},
|
||||
],
|
||||
{ client },
|
||||
{ client, audio },
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { pluginRegistry } from "../registry";
|
||||
import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
createPluginNavigation: () => ({}),
|
||||
}));
|
||||
@@ -37,6 +39,7 @@ function install(locations: readonly ("workspace" | "explorer")[]) {
|
||||
[{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }],
|
||||
{
|
||||
client: {} as DaemonClient,
|
||||
audio,
|
||||
},
|
||||
);
|
||||
return pluginRegistry.getSnapshot()[0]!;
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import { useCallback, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { ArrowLeftToLine, Plus, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout";
|
||||
import type {
|
||||
DraggableListDragHandleProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "@/components/draggable-list.types";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { titlebarDragSurfaceStyle } from "@/components/desktop/titlebar-drag-region";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabPresentationResolver,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
useWorkspaceTabLaunchCatalog,
|
||||
type WorkspaceTabLaunchItem,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import {
|
||||
HorizontalScrollBoundaryShades,
|
||||
useHorizontalScrollBoundary,
|
||||
} from "@/components/ui/horizontal-scroll-boundary";
|
||||
|
||||
const TAB_GAP = 4;
|
||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface ExplorerSidebarTabRailProps {
|
||||
paneId: string;
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
activeDragTabId: string | null;
|
||||
tabDropPreviewIndex: number | null;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCreateNewTab: () => void;
|
||||
onMoveTabToMain: (tabId: string) => void;
|
||||
onReorderTabs: (tabs: WorkspaceTabDescriptor[]) => void;
|
||||
trailingAccessory?: ReactNode;
|
||||
}
|
||||
|
||||
function tabKey(item: WorkspaceDesktopTabRowItem): string {
|
||||
return `${item.tab.key}:${item.tab.kind}`;
|
||||
}
|
||||
|
||||
function resolveExplorerSidebarTabBackdrop(): SurfaceBackdrop {
|
||||
return "surfaceSidebar";
|
||||
}
|
||||
|
||||
function ExplorerSidebarTab({
|
||||
item,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onMoveTabToMain,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
item: WorkspaceDesktopTabRowItem;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onMoveTabToMain: (tabId: string) => void;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handlePress = useCallback(
|
||||
() => onNavigateTab(item.tab.tabId),
|
||||
[item.tab.tabId, onNavigateTab],
|
||||
);
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
const handleClose = useCallback(() => {
|
||||
void onCloseTab(item.tab.tabId);
|
||||
}, [item.tab.tabId, onCloseTab]);
|
||||
const handleMoveToMain = useCallback(
|
||||
() => onMoveTabToMain(item.tab.tabId),
|
||||
[item.tab.tabId, onMoveTabToMain],
|
||||
);
|
||||
const canMoveToMain = panelTargetSupportsHost(normalizedServerId, item.tab.target, "main");
|
||||
const moveToMainLeading = useMemo(
|
||||
() => <ThemedArrowLeftToLine size={14} uniProps={mutedColorMapping} />,
|
||||
[],
|
||||
);
|
||||
const closeLeading = useMemo(() => <ThemedX size={14} uniProps={mutedColorMapping} />, []);
|
||||
const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]);
|
||||
const renderPresentation = useCallback(
|
||||
(presentation: WorkspaceTabPresentation) => (
|
||||
<ContextMenu>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<ContextMenuTrigger
|
||||
{...(dragHandleProps?.attributes as object | undefined)}
|
||||
{...(dragHandleProps?.listeners as object | undefined)}
|
||||
triggerRef={dragHandleProps?.setActivatorNodeRef as never}
|
||||
testID={`explorer-sidebar-tab-${item.tab.tabId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={presentation.tooltip}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={[
|
||||
styles.tab,
|
||||
hovered ? styles.tabHovered : null,
|
||||
item.isActive ? styles.tabActive : null,
|
||||
isDragging ? styles.tabDragging : null,
|
||||
]}
|
||||
>
|
||||
<WorkspaceTabIcon
|
||||
presentation={presentation}
|
||||
active={item.isActive}
|
||||
size={iconButtonChromeGlyphSize("small")}
|
||||
strokeWidth={1.5}
|
||||
backdrop={resolveExplorerSidebarTabBackdrop()}
|
||||
/>
|
||||
<Text
|
||||
selectable={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
style={[styles.tabLabel, item.isActive ? styles.tabLabelActive : null]}
|
||||
>
|
||||
{presentation.label}
|
||||
</Text>
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{presentation.tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ContextMenuContent align="start" minWidth={180}>
|
||||
{canMoveToMain ? (
|
||||
<ContextMenuItem leading={moveToMainLeading} onSelect={handleMoveToMain}>
|
||||
{t("workspace.tabs.menu.moveToMain")}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{canMoveToMain ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem leading={closeLeading} onSelect={handleClose}>
|
||||
{t("workspace.tabs.menu.close")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
[
|
||||
accessibilityState,
|
||||
dragHandleProps,
|
||||
handleHoverIn,
|
||||
handleHoverOut,
|
||||
handleClose,
|
||||
handleMoveToMain,
|
||||
handlePress,
|
||||
hovered,
|
||||
isDragging,
|
||||
item,
|
||||
canMoveToMain,
|
||||
closeLeading,
|
||||
moveToMainLeading,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={item.tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{renderPresentation}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ComponentType<PanelIconProps>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={14} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedCatalogIcon = withUnistyles(CatalogIcon);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
function ExplorerSidebarConfigurationItem({
|
||||
item,
|
||||
paneId,
|
||||
tab,
|
||||
onCloseTab,
|
||||
}: {
|
||||
item: WorkspaceTabLaunchItem;
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor | null;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
return item.Icon ? <ThemedCatalogIcon Icon={item.Icon} uniProps={mutedColorMapping} /> : null;
|
||||
}, [item.Icon]);
|
||||
const handleSelect = useCallback(() => {
|
||||
if (tab) {
|
||||
void onCloseTab(tab.tabId);
|
||||
return;
|
||||
}
|
||||
item.launch({ kind: "open", paneId });
|
||||
}, [item, onCloseTab, paneId, tab]);
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
leading={leading}
|
||||
selected={Boolean(tab)}
|
||||
showSelectedCheck
|
||||
disabled={!tab && item.disabled}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{item.label}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean {
|
||||
return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target);
|
||||
}
|
||||
|
||||
export function ExplorerSidebarTabRail({
|
||||
paneId,
|
||||
tabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
activeDragTabId,
|
||||
tabDropPreviewIndex,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCreateNewTab,
|
||||
onMoveTabToMain,
|
||||
onReorderTabs,
|
||||
trailingAccessory,
|
||||
}: ExplorerSidebarTabRailProps) {
|
||||
const scrollBoundary = useHorizontalScrollBoundary();
|
||||
const { t } = useTranslation();
|
||||
const groups = useWorkspaceTabLaunchCatalog({
|
||||
serverId: normalizedServerId,
|
||||
purpose: "supporting",
|
||||
host: "explorer",
|
||||
});
|
||||
const singletonConfigurationItems = useMemo(
|
||||
() => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null),
|
||||
[groups],
|
||||
);
|
||||
const newTabLeading = useMemo(() => <ThemedPlus size={14} uniProps={mutedColorMapping} />, []);
|
||||
const handleDragEnd = useCallback(
|
||||
(nextTabs: WorkspaceDesktopTabRowItem[]) => onReorderTabs(nextTabs.map((item) => item.tab)),
|
||||
[onReorderTabs],
|
||||
);
|
||||
const getTabDragData = useCallback(
|
||||
(item: WorkspaceDesktopTabRowItem) => ({
|
||||
kind: "workspace-tab" as const,
|
||||
paneId,
|
||||
tabId: item.tab.tabId,
|
||||
}),
|
||||
[paneId],
|
||||
);
|
||||
const renderTab = useCallback(
|
||||
({
|
||||
item,
|
||||
index,
|
||||
dragHandleProps,
|
||||
isActive,
|
||||
}: DraggableRenderItemInfo<WorkspaceDesktopTabRowItem>) => {
|
||||
const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index;
|
||||
const showAfter =
|
||||
activeDragTabId !== null &&
|
||||
tabDropPreviewIndex === tabs.length &&
|
||||
index === tabs.length - 1;
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showBefore ? <View style={[styles.dropIndicator, styles.dropIndicatorBefore]} /> : null}
|
||||
<ExplorerSidebarTab
|
||||
item={item}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onMoveTabToMain={onMoveTabToMain}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
{showAfter ? <View style={[styles.dropIndicator, styles.dropIndicatorAfter]} /> : null}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeDragTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onMoveTabToMain,
|
||||
tabDropPreviewIndex,
|
||||
tabs.length,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
contextOnly
|
||||
style={[styles.track, titlebarDragSurfaceStyle as never]}
|
||||
testID="explorer-sidebar-tab-rail"
|
||||
>
|
||||
<View style={styles.scrollContainer}>
|
||||
<Animated.ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onLayout={scrollBoundary.onLayout}
|
||||
onContentSizeChange={scrollBoundary.onContentSizeChange}
|
||||
onScroll={scrollBoundary.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<SortableInlineList
|
||||
data={tabs}
|
||||
keyExtractor={tabKey}
|
||||
renderItem={renderTab}
|
||||
onDragEnd={handleDragEnd}
|
||||
useDragHandle
|
||||
externalDndContext
|
||||
activeId={activeDragTabId}
|
||||
getItemData={getTabDragData}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
<HorizontalScrollBoundaryShades
|
||||
visible
|
||||
backdrop="sidebar"
|
||||
testIDPrefix="explorer-sidebar-tabs-scroll-shade"
|
||||
leftStyle={scrollBoundary.leftShadeStyle}
|
||||
rightStyle={scrollBoundary.rightShadeStyle}
|
||||
/>
|
||||
</View>
|
||||
{trailingAccessory ? (
|
||||
<View style={styles.trailingAccessory}>{trailingAccessory}</View>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent align="start" minWidth={200} testID="explorer-sidebar-tab-configuration">
|
||||
<ContextMenuItem leading={newTabLeading} onSelect={onCreateNewTab}>
|
||||
{t("workspace.tabs.actions.newTab")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
{singletonConfigurationItems.map((item) => (
|
||||
<ExplorerSidebarConfigurationItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
paneId={paneId}
|
||||
tab={tabs.find(({ tab }) => catalogItemMatchesTab(item, tab))?.tab ?? null}
|
||||
onCloseTab={onCloseTab}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
track: {
|
||||
minWidth: 0,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
scrollContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: EXPLORER_TAB_RAIL_INSET,
|
||||
},
|
||||
trailingAccessory: {
|
||||
marginRight: 4,
|
||||
},
|
||||
tabSlot: {
|
||||
position: "relative",
|
||||
marginHorizontal: TAB_GAP / 2,
|
||||
},
|
||||
tab: {
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
maxWidth: 180,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
tabHovered: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
tabLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
userSelect: "none",
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
tabDragging: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
dropIndicator: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[0.5],
|
||||
bottom: theme.spacing[0.5],
|
||||
width: TAB_DROP_INDICATOR_WIDTH,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
dropIndicatorBefore: {
|
||||
left: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
|
||||
},
|
||||
dropIndicatorAfter: {
|
||||
right: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.popoverForeground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -4,17 +4,35 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import type { TabDropPreview } from "@/components/split-container-tab-drop-preview";
|
||||
import { ExplorerSidebarTabRail } from "@/screens/workspace/explorer-sidebar-tab-rail";
|
||||
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
type WorkspaceDesktopTabsRowProps,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SplitPane } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
|
||||
interface ExplorerSidebarDockProps {
|
||||
interface ExplorerSidebarDockProps extends Pick<
|
||||
WorkspaceDesktopTabsRowProps,
|
||||
| "setHoveredCloseTabKey"
|
||||
| "onCopyResumeCommand"
|
||||
| "onCopyAgentId"
|
||||
| "onCopyTerminalId"
|
||||
| "onCopyFilePath"
|
||||
| "onReloadAgent"
|
||||
| "onRenameTab"
|
||||
| "onCreateNewTab"
|
||||
| "onExitFocusMode"
|
||||
> {
|
||||
hoveredCloseTabKey: string | null;
|
||||
onCloseTabsToLeft: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
pane: SplitPane;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
@@ -25,8 +43,6 @@ interface ExplorerSidebarDockProps {
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
onSelectTab: (paneId: string, tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCreateNewTab: () => void;
|
||||
onMoveTabToMain: (tabId: string) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
@@ -48,7 +64,18 @@ export function ExplorerSidebarDock({
|
||||
onSelectTab,
|
||||
onCloseTab,
|
||||
onCreateNewTab,
|
||||
onMoveTabToMain,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCopyTerminalId,
|
||||
onCopyFilePath,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onExitFocusMode,
|
||||
onReorderTabsInPane,
|
||||
buildPaneContentModel,
|
||||
headerAction,
|
||||
@@ -61,10 +88,10 @@ export function ExplorerSidebarDock({
|
||||
tabs.map((tab) => ({
|
||||
tab,
|
||||
isActive: tab.tabId === activeTabId,
|
||||
isCloseHovered: false,
|
||||
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||
isClosingTab: closingTabIds.has(tab.tabId),
|
||||
})),
|
||||
[activeTabId, closingTabIds, tabs],
|
||||
[activeTabId, closingTabIds, hoveredCloseTabKey, tabs],
|
||||
);
|
||||
const handleSelectTab = useCallback(
|
||||
(tabId: string) => onSelectTab(pane.id, tabId),
|
||||
@@ -80,28 +107,59 @@ export function ExplorerSidebarDock({
|
||||
[onReorderTabsInPane, pane.id],
|
||||
);
|
||||
|
||||
const handleCloseTabsToLeft = useCallback(
|
||||
(tabId: string) => onCloseTabsToLeft(tabId, tabs),
|
||||
[onCloseTabsToLeft, tabs],
|
||||
);
|
||||
const handleCloseTabsToRight = useCallback(
|
||||
(tabId: string) => onCloseTabsToRight(tabId, tabs),
|
||||
[onCloseTabsToRight, tabs],
|
||||
);
|
||||
const handleCloseOtherTabs = useCallback(
|
||||
(tabId: string) => onCloseOtherTabs(tabId, tabs),
|
||||
[onCloseOtherTabs, tabs],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanel active>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={styles.dock} testID="workspace-explorer-sidebar">
|
||||
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
|
||||
<TitlebarDragRegion />
|
||||
<ExplorerSidebarTabRail
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onMoveTabToMain={onMoveTabToMain}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
trailingAccessory={headerAction}
|
||||
/>
|
||||
<View style={styles.tabRow}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
host="explorer"
|
||||
launchPurpose="supporting"
|
||||
isFocused={isWorkspaceFocused}
|
||||
ownsKeyboardShortcuts={false}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
focusModeEnabled={false}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
externalDndContext
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</View>
|
||||
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
|
||||
<View pointerEvents="none" style={styles.tabRailDivider} />
|
||||
</WindowChromeSafeArea>
|
||||
<View style={styles.content}>
|
||||
@@ -131,9 +189,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
tabRail: {
|
||||
position: "relative",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
tabRow: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerAction: {
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
tabRailDivider: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
|
||||
@@ -63,6 +63,8 @@ import {
|
||||
type WorkspaceTabMenuLabels,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
@@ -107,7 +109,7 @@ const TAB_ICON_WIDTH = 14;
|
||||
const TAB_CONTENT_GAP = 4;
|
||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||
const TAB_MODIFIED_DOT_SIZE = 8;
|
||||
const TAB_MIN_WIDTH = 96;
|
||||
const TAB_MIN_WIDTH = 64;
|
||||
const TAB_MAX_WIDTH = 160;
|
||||
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
||||
const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
|
||||
@@ -209,7 +211,12 @@ function TabLabelMeasurement({
|
||||
);
|
||||
}
|
||||
|
||||
type PanePanelKinds = readonly WorkspaceTabDescriptor["kind"][];
|
||||
|
||||
interface WorkspaceNewTabButtonProps {
|
||||
panePanelKinds: PanePanelKinds;
|
||||
host: PaneHost;
|
||||
launchPurpose: WorkspaceTabLaunchPurpose;
|
||||
serverId: string;
|
||||
paneId?: string;
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
@@ -217,6 +224,9 @@ interface WorkspaceNewTabButtonProps {
|
||||
}
|
||||
|
||||
function WorkspaceNewTabButton({
|
||||
panePanelKinds,
|
||||
host,
|
||||
launchPurpose,
|
||||
serverId,
|
||||
paneId,
|
||||
shortcutKeys,
|
||||
@@ -237,8 +247,9 @@ function WorkspaceNewTabButton({
|
||||
</ToolbarButton>
|
||||
<WorkspaceNewTabMenuContent
|
||||
serverId={serverId}
|
||||
purpose="primary"
|
||||
host="main"
|
||||
purpose={launchPurpose}
|
||||
host={host}
|
||||
panePanelKinds={panePanelKinds}
|
||||
paneId={paneId}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
@@ -248,6 +259,9 @@ function WorkspaceNewTabButton({
|
||||
}
|
||||
|
||||
function WorkspacePaneToolbarActions({
|
||||
panePanelKinds,
|
||||
host,
|
||||
launchPurpose,
|
||||
showNewTabButton,
|
||||
showSplitActions,
|
||||
showMaximizeAction,
|
||||
@@ -259,6 +273,9 @@ function WorkspacePaneToolbarActions({
|
||||
onSplitDown,
|
||||
onTogglePaneMaximized,
|
||||
}: {
|
||||
panePanelKinds: PanePanelKinds;
|
||||
host: PaneHost;
|
||||
launchPurpose: WorkspaceTabLaunchPurpose;
|
||||
showNewTabButton: boolean;
|
||||
showSplitActions: boolean;
|
||||
showMaximizeAction: boolean;
|
||||
@@ -297,6 +314,9 @@ function WorkspacePaneToolbarActions({
|
||||
<ToolbarControls style={styles.paneSplitActions}>
|
||||
{showNewTabButton ? (
|
||||
<WorkspaceNewTabButton
|
||||
panePanelKinds={panePanelKinds}
|
||||
host={host}
|
||||
launchPurpose={launchPurpose}
|
||||
placement="toolbar"
|
||||
serverId={serverId}
|
||||
paneId={paneId}
|
||||
@@ -492,9 +512,12 @@ function sameWidths(left: number[], right: number[]): boolean {
|
||||
return left.length === right.length && left.every((width, index) => width === right[index]);
|
||||
}
|
||||
|
||||
interface WorkspaceDesktopTabsRowProps {
|
||||
export interface WorkspaceDesktopTabsRowProps {
|
||||
host?: PaneHost;
|
||||
launchPurpose?: WorkspaceTabLaunchPurpose;
|
||||
paneId?: string;
|
||||
isFocused?: boolean;
|
||||
ownsKeyboardShortcuts?: boolean;
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -988,8 +1011,11 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
|
||||
}
|
||||
|
||||
function ResolvedWorkspaceDesktopTabsRow({
|
||||
host = "main",
|
||||
launchPurpose = "primary",
|
||||
paneId,
|
||||
isFocused = false,
|
||||
ownsKeyboardShortcuts = isFocused,
|
||||
tabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -1059,6 +1085,8 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
[exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions],
|
||||
);
|
||||
|
||||
const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]);
|
||||
|
||||
const fallbackTabLabels = useMemo(
|
||||
() => ({
|
||||
newTab: t("workspace.tabs.actions.newTab"),
|
||||
@@ -1203,14 +1231,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
|
||||
const handleNewTabKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isFocused) return false;
|
||||
if (!ownsKeyboardShortcuts) return false;
|
||||
if (action.id === "workspace.tab.menu.open") {
|
||||
createNewTab();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[createNewTab, isFocused],
|
||||
[createNewTab, ownsKeyboardShortcuts],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -1221,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
paneId,
|
||||
}),
|
||||
actions: ["workspace.tab.menu.open"],
|
||||
enabled: isFocused,
|
||||
enabled: ownsKeyboardShortcuts,
|
||||
priority: 200,
|
||||
handle: handleNewTabKeyboardAction,
|
||||
});
|
||||
@@ -1233,7 +1261,8 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
dragHandleProps,
|
||||
isActive,
|
||||
}: DraggableRenderItemInfo<ResolvedWorkspaceDesktopTabRowItem>) => {
|
||||
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
|
||||
const shouldShowCloseButton =
|
||||
layout.closeButtonPolicy === "all" && item.presentation.showCloseButton;
|
||||
const layoutItem = layout.items[index] ?? null;
|
||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||
const showLabel = layoutItem?.showLabel ?? true;
|
||||
@@ -1360,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
{!layout.requiresHorizontalScrollFallback ? (
|
||||
<WorkspaceNewTabButton
|
||||
panePanelKinds={panePanelKinds}
|
||||
host={host}
|
||||
launchPurpose={launchPurpose}
|
||||
placement="inline"
|
||||
serverId={normalizedServerId}
|
||||
paneId={paneId}
|
||||
@@ -1376,6 +1408,9 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
</View>
|
||||
<WorkspacePaneToolbarActions
|
||||
panePanelKinds={panePanelKinds}
|
||||
host={host}
|
||||
launchPurpose={launchPurpose}
|
||||
showNewTabButton={layout.requiresHorizontalScrollFallback}
|
||||
showSplitActions={showPaneSplitActions}
|
||||
showMaximizeAction={showPaneMaximizeAction}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type WorkspaceTabLaunchItem,
|
||||
type WorkspaceTabLaunchPurpose,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
@@ -84,14 +85,22 @@ export function WorkspaceNewTabMenuContent({
|
||||
serverId,
|
||||
purpose,
|
||||
host,
|
||||
panePanelKinds,
|
||||
paneId,
|
||||
}: {
|
||||
serverId: string;
|
||||
purpose: WorkspaceTabLaunchPurpose;
|
||||
host: PaneHost;
|
||||
panePanelKinds: readonly WorkspaceTabTarget["kind"][];
|
||||
paneId?: string;
|
||||
}) {
|
||||
const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose, host });
|
||||
const groups = useWorkspaceTabLaunchCatalog({
|
||||
serverId,
|
||||
purpose,
|
||||
host,
|
||||
surface: "menu",
|
||||
panePanelKinds,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -9,7 +9,7 @@ const metrics = {
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: 8,
|
||||
tabGap: 4,
|
||||
minTabWidth: 96,
|
||||
minTabWidth: 64,
|
||||
maxTabWidth: 160,
|
||||
tabIconWidth: 14,
|
||||
tabContentGap: 4,
|
||||
@@ -29,7 +29,20 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]);
|
||||
});
|
||||
|
||||
it("lets short labels use their content width below the old 96px floor", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 300,
|
||||
tabLabelWidths: [29, 52],
|
||||
metrics: { ...metrics, actionsReservedWidth: 36 },
|
||||
});
|
||||
expect(result.items).toEqual([
|
||||
{ width: 64, showLabel: true },
|
||||
{ width: 86, showLabel: true },
|
||||
]);
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
});
|
||||
|
||||
it("sizes a single tab between the minimum and maximum from its content", () => {
|
||||
@@ -52,7 +65,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([132, 102, 82]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -75,27 +88,27 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
it("keeps every tab at the clickable minimum at the exact fit boundary", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 532,
|
||||
viewportWidth: 404,
|
||||
tabLabelWidths: [98, 98, 98, 98],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||
expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses horizontal scroll rather than shrinking below the clickable minimum", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
viewportWidth: 531,
|
||||
viewportWidth: 403,
|
||||
tabLabelWidths: [98, 98, 98, 98],
|
||||
metrics,
|
||||
});
|
||||
|
||||
expect(result.closeButtonPolicy).toBe("all");
|
||||
expect(result.requiresHorizontalScrollFallback).toBe(true);
|
||||
expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]);
|
||||
expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]);
|
||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation {
|
||||
subtitle: string;
|
||||
tooltip: string;
|
||||
modified: boolean;
|
||||
showCloseButton: boolean;
|
||||
titleState: "ready" | "loading";
|
||||
icon: React.ComponentType<PanelIconProps>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
@@ -90,6 +91,7 @@ function WorkspaceTabPresentationResolverInner({
|
||||
subtitle: descriptor.subtitle,
|
||||
tooltip: descriptor.tooltip,
|
||||
modified: attributes.modified,
|
||||
showCloseButton: registration.showCloseButton,
|
||||
titleState: descriptor.titleState,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
@@ -104,6 +106,7 @@ function WorkspaceTabPresentationResolverInner({
|
||||
tab.key,
|
||||
tab.kind,
|
||||
attributes.modified,
|
||||
registration.showCloseButton,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -597,6 +597,8 @@ export const ICON_SIZE = {
|
||||
lg: 20,
|
||||
} as const;
|
||||
|
||||
export const ICON_STROKE_WIDTH = 1.5;
|
||||
|
||||
export const FONT_WEIGHT = {
|
||||
normal: "normal" as const,
|
||||
medium: "500" as const,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getPanelManifest } from "@/panels/panel-manifest";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPre
|
||||
...data,
|
||||
tooltip: data.label,
|
||||
modified: false,
|
||||
showCloseButton: getPanelManifest(data.kind).showCloseButton,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,11 +73,14 @@ function settleMountRefits(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 2_600));
|
||||
}
|
||||
|
||||
function createTerminalHost(input: {
|
||||
interface CreateTerminalHostInput {
|
||||
width: number;
|
||||
height: number;
|
||||
scrollback?: number;
|
||||
}): MountedTerminal {
|
||||
isMac?: boolean;
|
||||
}
|
||||
|
||||
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
const root = document.createElement("div");
|
||||
root.style.width = `${input.width}px`;
|
||||
root.style.height = `${input.height}px`;
|
||||
@@ -97,7 +100,9 @@ function createTerminalHost(input: {
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const runtime = new TerminalEmulatorRuntime(
|
||||
input.isMac === undefined ? undefined : { isMac: input.isMac },
|
||||
);
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput: (data) => {
|
||||
@@ -176,30 +181,37 @@ function getBrowserTerminal(): BrowserTerminal {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
function dispatchTerminalKey(input: {
|
||||
interface DispatchTerminalKeyInput {
|
||||
host: HTMLElement;
|
||||
key: string;
|
||||
keyCode?: number;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
}): boolean {
|
||||
}
|
||||
|
||||
function dispatchTerminalKey(input: DispatchTerminalKeyInput): boolean {
|
||||
const textarea = input.host.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected xterm textarea to be mounted");
|
||||
}
|
||||
textarea.focus();
|
||||
return textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: input.key,
|
||||
shiftKey: input.shiftKey ?? false,
|
||||
ctrlKey: input.ctrlKey ?? false,
|
||||
altKey: input.altKey ?? false,
|
||||
metaKey: input.metaKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: input.key,
|
||||
shiftKey: input.shiftKey ?? false,
|
||||
ctrlKey: input.ctrlKey ?? false,
|
||||
altKey: input.altKey ?? false,
|
||||
metaKey: input.metaKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
if (input.keyCode !== undefined) {
|
||||
// Synthetic KeyboardEvents always report keyCode 0; xterm's fallback encoder
|
||||
// switches on keyCode, so hardware-like events need it filled in.
|
||||
Object.defineProperty(event, "keyCode", { value: input.keyCode });
|
||||
}
|
||||
return textarea.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
|
||||
@@ -514,6 +526,78 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("translates mac editing shortcuts into shell editing sequences", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: true });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 });
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true });
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true });
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true });
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true });
|
||||
dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true });
|
||||
await nextFrame();
|
||||
|
||||
expect(mounted.inputs).toEqual([
|
||||
"\x1b[C", // plain arrow keeps xterm's default encoding
|
||||
"\x01", // cmd+left -> line start
|
||||
"\x05", // cmd+right -> line end
|
||||
"\x1bb", // option+left -> word back
|
||||
"\x1bf", // option+right -> word forward
|
||||
"\x15", // cmd+backspace -> kill line
|
||||
]);
|
||||
expect(mounted.terminalKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps pending modifier chips authoritative (isMac=%s)", async (isMac) => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
mounted.runtime.setPendingModifiers({
|
||||
pendingModifiers: { ctrl: true, alt: false, shift: false },
|
||||
});
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true });
|
||||
await nextFrame();
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
expect(mounted.terminalKeys).toEqual([
|
||||
{ key: "ArrowLeft", ctrl: true, alt: true, shift: false, meta: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps cmd+shift+arrow free for app-level shortcuts", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: true });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
dispatchTerminalKey({
|
||||
host: mounted.host,
|
||||
key: "ArrowRight",
|
||||
keyCode: 39,
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
expect(mounted.terminalKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not remap modified arrows on non-mac platforms", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true });
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true });
|
||||
await nextFrame();
|
||||
|
||||
// Meta+arrow is ignored by xterm; alt+arrow takes xterm's generic encoding.
|
||||
expect(mounted.inputs).toEqual(["\x1b[1;3C"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "DA1", bytes: "\x1b[c" },
|
||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||
|
||||
@@ -241,6 +241,8 @@ describe("terminal-emulator-runtime", () => {
|
||||
hasSelection: () => false,
|
||||
getSelection: () => "",
|
||||
paste: () => {},
|
||||
input: () => {},
|
||||
scrollToBottom: () => {},
|
||||
});
|
||||
expect(passedToXterm).toBe(!opensFind);
|
||||
expect(findRequests).toBe(opensFind ? 1 : 0);
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
} from "@getpaseo/protocol/terminal-input-mode";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
hasPendingTerminalModifiers,
|
||||
isAppleHandheldPlatform,
|
||||
isTerminalModifierDomKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
normalizeTerminalTransportKey,
|
||||
resolveMacTerminalEditingShortcut,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||
@@ -299,7 +301,12 @@ export class TerminalEmulatorRuntime {
|
||||
attachKeyEventHandler(
|
||||
terminal: Pick<
|
||||
Terminal,
|
||||
"attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste"
|
||||
| "attachCustomKeyEventHandler"
|
||||
| "hasSelection"
|
||||
| "getSelection"
|
||||
| "paste"
|
||||
| "input"
|
||||
| "scrollToBottom"
|
||||
>,
|
||||
): void {
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
@@ -339,6 +346,10 @@ export class TerminalEmulatorRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.maybeSendMacEditingShortcut(terminal, event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||
return true;
|
||||
@@ -371,7 +382,7 @@ export class TerminalEmulatorRuntime {
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
|
||||
if (hasPendingTerminalModifiers(this.pendingModifiers)) {
|
||||
this.callbacks.onPendingModifiersConsumed?.();
|
||||
}
|
||||
|
||||
@@ -426,6 +437,26 @@ export class TerminalEmulatorRuntime {
|
||||
return this.inputModeTracker.getState();
|
||||
}
|
||||
|
||||
private maybeSendMacEditingShortcut(
|
||||
terminal: Pick<Terminal, "input" | "scrollToBottom">,
|
||||
event: KeyboardEvent,
|
||||
): boolean {
|
||||
if (!this.options.isMac || hasPendingTerminalModifiers(this.pendingModifiers)) {
|
||||
return false;
|
||||
}
|
||||
const editingShortcutData = resolveMacTerminalEditingShortcut(event);
|
||||
if (editingShortcutData === null) {
|
||||
return false;
|
||||
}
|
||||
// Routed through terminal.input() so the data takes the same path as typed
|
||||
// keys: onData -> callbacks.onInput, selection clearing included.
|
||||
terminal.input(editingShortcutData, true);
|
||||
terminal.scrollToBottom();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
mount(input: TerminalEmulatorRuntimeMountInput): void {
|
||||
this.unmount();
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,6 +27,19 @@ function statusText(report: UsageReport): string | null {
|
||||
return report.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function reportContent(report: UsageReport) {
|
||||
if (report.status === "available")
|
||||
return {
|
||||
windows: report.windows,
|
||||
balances: report.balances ?? [],
|
||||
details: report.details ?? [],
|
||||
message: undefined,
|
||||
};
|
||||
const message =
|
||||
report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error;
|
||||
return { windows: [], balances: [], details: [], message };
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
@@ -48,8 +61,7 @@ export function UsageCard({
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
const { windows, balances, details, message } = reportContent(usage);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -71,7 +83,9 @@ export function UsageCard({
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" size="xs" /> : null}
|
||||
{usage.status === "available" && usage.planLabel ? (
|
||||
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
|
||||
) : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
@@ -88,15 +102,15 @@ export function UsageCard({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
{message ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
{windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
{windows.map((window) => (
|
||||
<PinnableWindowBar key={window.id} entry={entry} window={window} display={display} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
|
||||
@@ -2,8 +2,7 @@ import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { UsageOptionsMenu } from "./options-menu";
|
||||
import { UsageRefreshButton } from "./refresh-button";
|
||||
import type { UsageHost } from "./model";
|
||||
import { useHostUsage } from "./queries";
|
||||
import type { UsageView } from "./types";
|
||||
@@ -17,16 +16,14 @@ export interface UsageHostSelection {
|
||||
|
||||
/**
|
||||
* The controls on the right of every usage title row: the host filter when there is more than one
|
||||
* host, and the options menu. A host that cannot report usage keeps only the host filter.
|
||||
* host, and Refresh all. A host that cannot report usage keeps only the host filter.
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
display,
|
||||
onRefresh,
|
||||
hostSelection,
|
||||
}: {
|
||||
view: UsageView;
|
||||
display: UsageDisplay;
|
||||
onRefresh: () => void;
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
@@ -44,7 +41,7 @@ export function UsageControls({
|
||||
/>
|
||||
) : null}
|
||||
{view.kind === "unavailable" ? null : (
|
||||
<UsageOptionsMenu display={display} busy={busy} onRefresh={onRefresh} />
|
||||
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -67,21 +64,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
* One host's usage and the title-row controls that go with it, for the Usage screen and the
|
||||
* compact usage sheet.
|
||||
*/
|
||||
export function useHostUsageWithControls(
|
||||
hostSelection: UsageHostSelection,
|
||||
display: UsageDisplay,
|
||||
): { view: UsageView; refresh: () => void; controls: ReactElement } {
|
||||
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||
view: UsageView;
|
||||
refresh: () => void;
|
||||
controls: ReactElement;
|
||||
} {
|
||||
const { view, refresh } = useHostUsage(hostSelection.serverId);
|
||||
const controls = useMemo(
|
||||
() => (
|
||||
<UsageControls
|
||||
view={view}
|
||||
display={display}
|
||||
onRefresh={refresh}
|
||||
hostSelection={hostSelection}
|
||||
/>
|
||||
),
|
||||
[display, hostSelection, refresh, view],
|
||||
() => <UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />,
|
||||
[hostSelection, refresh, view],
|
||||
);
|
||||
return { view, refresh, controls };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { usageCopy } from "./copy";
|
||||
|
||||
test.each([
|
||||
[
|
||||
{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z", refreshedBy: "claude" },
|
||||
"Login expired 3h ago. Run claude to refresh it.",
|
||||
],
|
||||
[{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z" }, "Login expired 3h ago. Sign in again."],
|
||||
[
|
||||
{ kind: "rejected", status: 403, refreshedBy: "codex" },
|
||||
"Login rejected (HTTP 403). Run codex to refresh it.",
|
||||
],
|
||||
[{ kind: "rejected", status: 401 }, "Login rejected (HTTP 401). Sign in again."],
|
||||
[{ kind: "no_quota", detail: "No active coding plan" }, "No active coding plan"],
|
||||
] as const)("problem %j", (problem, sentence) => {
|
||||
expect(usageCopy.problem(problem, new Date("2026-10-01T12:00:00Z"))).toBe(sentence);
|
||||
});
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { UsageProblem } from "@getpaseo/protocol/messages";
|
||||
import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
|
||||
// User-facing copy for the usage surfaces, kept in one file so localization is a
|
||||
// single-file change.
|
||||
export const usageCopy = {
|
||||
problem: (problem: UsageProblem, now: Date = new Date()): string => {
|
||||
if (problem.kind === "no_quota") return problem.detail;
|
||||
const remedy = problem.refreshedBy
|
||||
? `Run ${problem.refreshedBy} to refresh it.`
|
||||
: "Sign in again.";
|
||||
if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`;
|
||||
const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now));
|
||||
return `Login expired ${ago}. ${remedy}`;
|
||||
},
|
||||
title: "Usage",
|
||||
planUsage: "Plan usage",
|
||||
options: "Usage options",
|
||||
options: "Settings",
|
||||
refresh: "Refresh",
|
||||
refreshAll: "Refresh all",
|
||||
refreshing: "Refreshing...",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
updated: "Updated",
|
||||
@@ -17,8 +30,10 @@ export const usageCopy = {
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
pin: "Pin",
|
||||
displayAs: "Show",
|
||||
unpin: "Unpin",
|
||||
displayAs: "Percentages",
|
||||
displayUsed: "Used",
|
||||
displayRemaining: "Remaining",
|
||||
showInSidebar: "Show in sidebar",
|
||||
showInSidebar: "Summary in sidebar",
|
||||
showInSidebarHint: "Pinned windows show in the sidebar footer",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useUsageHostId } from "./hosts";
|
||||
import { useUsageHostReports } from "./queries";
|
||||
import {
|
||||
isUsagePinned,
|
||||
setUsageDisplayAs,
|
||||
@@ -18,9 +20,14 @@ export interface UsageDisplay {
|
||||
}
|
||||
|
||||
/** The device's usage preferences, and the commands that change them. */
|
||||
export function useUsagePreferences(): { preferences: UsagePreferences; display: UsageDisplay } {
|
||||
export function useUsagePreferences(serverId?: string): {
|
||||
preferences: UsagePreferences;
|
||||
display: UsageDisplay;
|
||||
} {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const preferences = settings.usage;
|
||||
const defaultServerId = useUsageHostId();
|
||||
const reports = useUsageHostReports(serverId ?? defaultServerId);
|
||||
const setDisplayAs = useCallback(
|
||||
(displayAs: UsageDisplayAs) => {
|
||||
void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) }));
|
||||
@@ -29,18 +36,20 @@ export function useUsagePreferences(): { preferences: UsagePreferences; display:
|
||||
);
|
||||
const togglePin = useCallback(
|
||||
(pin: UsagePin) => {
|
||||
void updateSettings((current) => ({ usage: toggleUsagePin(current.usage, pin) }));
|
||||
void updateSettings((current) => ({
|
||||
usage: toggleUsagePin({ preferences: current.usage, pin, reports }),
|
||||
}));
|
||||
},
|
||||
[updateSettings],
|
||||
[updateSettings, reports],
|
||||
);
|
||||
const display = useMemo<UsageDisplay>(
|
||||
() => ({
|
||||
displayAs: preferences.displayAs,
|
||||
setDisplayAs,
|
||||
isPinned: (pin) => isUsagePinned(preferences, pin),
|
||||
isPinned: (pin) => isUsagePinned(preferences, pin, reports),
|
||||
togglePin,
|
||||
}),
|
||||
[preferences, setDisplayAs, togglePin],
|
||||
[preferences, reports, setDisplayAs, togglePin],
|
||||
);
|
||||
return { preferences, display };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { UsageSection } from "./usage-section";
|
||||
/** A host's usage reports, for its settings page. */
|
||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
||||
const { view, refresh } = useHostUsage(serverId);
|
||||
const { display } = useUsagePreferences();
|
||||
const { display } = useUsagePreferences(serverId);
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user