mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1b4e7898 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 | ||
|
|
4869214bcb | ||
|
|
ed87679c67 | ||
|
|
5eb4c8afad | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -107,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.
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+26
-16
@@ -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
|
||||
|
||||
@@ -473,7 +483,7 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
|
||||
+3
-1
@@ -188,7 +188,9 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
## Usage sources
|
||||
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
contract, account identity, login fallback, and read-only credential rules.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,7 +25,13 @@ checkout.forge.set_auto_merge.request;
|
||||
// -> checkout.forge.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
A finite streaming request can emit `.update` messages before its terminal `.response`.
|
||||
`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and
|
||||
`.response` carries the request-level error or `null`. All three share `requestId`; no updates
|
||||
follow the response. An empty successful stream is an empty list. Register updates as replies
|
||||
of the originating request so source-owned delivery routes them to the requesting connection.
|
||||
|
||||
Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-UZ9fPdCIJzghuxPXK9Kzt5fJmjyVwfaWsGiwXAh/zZI=
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
|
||||
Generated
+30
-13
@@ -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",
|
||||
@@ -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",
|
||||
@@ -40135,6 +40150,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
type AgentUsageScript,
|
||||
closeContextWindowSheet,
|
||||
contextWindowSheet,
|
||||
expectRefreshButtons,
|
||||
expiredLogin,
|
||||
gate,
|
||||
hoverContextWindowMeter,
|
||||
leaveContextWindowMeter,
|
||||
type MockAgentSession,
|
||||
onPersonalLogin,
|
||||
onWorkLogin,
|
||||
openAgent,
|
||||
pressContextWindowMeter,
|
||||
qaScreenshot,
|
||||
refreshUsageCard,
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const test = base.extend<{ agent: MockAgentSession }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedAgentWithContextWindow();
|
||||
try {
|
||||
await provide(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
@@ -69,3 +102,184 @@ test.describe("context window meter", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
|
||||
|
||||
type OpenDetails = (page: Page) => Promise<Locator>;
|
||||
type Shot = (state: string) => Promise<void>;
|
||||
|
||||
/** Two reports on the agent's login stream in; the details show each card as it arrives. */
|
||||
async function expectCardsToStreamIn(
|
||||
page: Page,
|
||||
usage: AgentUsageScript,
|
||||
openDetails: OpenDetails,
|
||||
shot: Shot,
|
||||
): Promise<Locator> {
|
||||
const [claude, codex] = claudeAndCodexReports();
|
||||
const first = gate();
|
||||
const second = gate();
|
||||
usage.answerNext({
|
||||
stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)],
|
||||
});
|
||||
const details = await openDetails(page);
|
||||
await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await shot("loading");
|
||||
|
||||
first.open();
|
||||
await expect(
|
||||
usageCard(details, "claude:work").getByText("work@example.com", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
|
||||
await expect(usageCard(details, "codex:work")).toHaveCount(0);
|
||||
await shot("streaming");
|
||||
|
||||
second.open();
|
||||
await expect(
|
||||
usageCard(details, "codex:work").getByText("Session", { exact: true }),
|
||||
).toBeVisible();
|
||||
// Only the agent's login, never the host's default one, and no pins.
|
||||
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
|
||||
await expectUnpinnableRows(details);
|
||||
await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2);
|
||||
return details;
|
||||
}
|
||||
|
||||
async function expectFailedRequestSentence(
|
||||
page: Page,
|
||||
usage: AgentUsageScript,
|
||||
openDetails: OpenDetails,
|
||||
shot: Shot,
|
||||
): Promise<void> {
|
||||
usage.answerNext({ error: "Unknown agent" });
|
||||
await reloadAgent(page);
|
||||
const details = await openDetails(page);
|
||||
await expect(
|
||||
details.getByText("Unable to load usage: Unknown agent", { exact: true }),
|
||||
).toBeVisible();
|
||||
await shot("error");
|
||||
}
|
||||
|
||||
async function expectOnlyContextWindowWithoutUsage(
|
||||
page: Page,
|
||||
usage: AgentUsageScript,
|
||||
openDetails: OpenDetails,
|
||||
shot: Shot,
|
||||
): Promise<void> {
|
||||
usage.stopReportingUsage();
|
||||
await reloadAgent(page);
|
||||
const details = await openDetails(page);
|
||||
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
|
||||
await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
await shot("unsupported");
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => {
|
||||
test.setTimeout(240_000);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`);
|
||||
|
||||
await test.step("reports stream in one card at a time, without Refresh", async () => {
|
||||
const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
|
||||
// The tooltip cannot be pressed, so its cards have no Refresh.
|
||||
await expectRefreshButtons(tooltip, 0);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("after a resume under another login, only that login shows", async () => {
|
||||
// Closed while the old login's report is still on its way.
|
||||
await leaveContextWindowMeter(page);
|
||||
const slowSource = gate();
|
||||
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
|
||||
const closedEarly = await hoverContextWindowMeter(page);
|
||||
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
const resumed = gate();
|
||||
const finished = gate();
|
||||
usage.answerNext({
|
||||
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
|
||||
});
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
|
||||
// The old login's report lands during the new request, before the new login's.
|
||||
slowSource.open();
|
||||
resumed.open();
|
||||
await expect(
|
||||
usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
finished.open();
|
||||
});
|
||||
|
||||
await test.step("a report with a problem shows it on the card", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
await reloadAgent(page);
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
await test.step("a failed request says so in a sentence", async () => {
|
||||
await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot);
|
||||
});
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
||||
// One request per open that had usage; this one sends none.
|
||||
expect(usage.agentRequests()).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.setViewportSize(COMPACT);
|
||||
await openAgent(page, agent);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`);
|
||||
|
||||
await test.step("reports stream in one card at a time, each with Refresh", async () => {
|
||||
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
|
||||
await expectRefreshButtons(sheet, 2);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
const sheet = contextWindowSheet(page);
|
||||
await refreshUsageCard(sheet, "Claude");
|
||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
await test.step("Close dismisses the sheet", async () => {
|
||||
await closeContextWindowSheet(page);
|
||||
});
|
||||
|
||||
await test.step("a failed request says so in a sentence", async () => {
|
||||
await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot);
|
||||
});
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
||||
expect(usage.agentRequests()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarFooterPreferences,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -154,8 +155,9 @@ test.describe("Plugin sidebar items", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
// The Usage item shows only with summary data.
|
||||
// The Usage item shows only with summary data, once it is turned on.
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
expectSettingsHostPickerLabel,
|
||||
openSettingsHostSection,
|
||||
removeCurrentHostFromSettings,
|
||||
scrollSettingsSidebarToEnd,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
@@ -77,6 +79,23 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test.describe("in a short window", () => {
|
||||
test.use({ viewport: { width: 1280, height: 480 } });
|
||||
|
||||
test("selecting a section keeps the sidebar scroll position", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await openSettingsHostSection(page, serverId, "plugins");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
|
||||
await openSettingsHostSection(page, serverId, "terminals");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]);
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "add-project", visible: false },
|
||||
{ key: "usage", visible: true },
|
||||
]);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
@@ -188,6 +191,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
// Keys for the fixed footer icon buttons are ignored.
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "usage", visible: true },
|
||||
{ key: "help", visible: false },
|
||||
{ key: "hosts", visible: false },
|
||||
{ key: "import", visible: false },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
installUsageReportsFixture,
|
||||
type UsageListRequest,
|
||||
@@ -15,11 +16,22 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import { expectPinnedUsage, togglePin, usageItem } from "../support/helpers/usage-sidebar-item";
|
||||
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({
|
||||
@@ -46,6 +58,11 @@ test.use({
|
||||
},
|
||||
});
|
||||
|
||||
// These tests read the sidebar summary and pin windows, which both need the Usage item on.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
});
|
||||
|
||||
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
|
||||
return usage.listRequests().filter((request) => request.forceRefresh);
|
||||
}
|
||||
@@ -226,6 +243,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);
|
||||
});
|
||||
|
||||
@@ -312,29 +331,28 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
||||
).toHaveCount(0);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "0");
|
||||
await expect(row).toBeChecked();
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await hoverUsageWindow(page, "Weekly");
|
||||
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await qaScreenshot(page, "usage-pin-hover");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(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 expectPinnedUsage(page, ["54% wk"]);
|
||||
await qaScreenshot(page, "usage-pin-selected");
|
||||
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();
|
||||
@@ -348,11 +366,127 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-unpinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(1)).toHaveCSS("opacity", "1");
|
||||
await 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" });
|
||||
}
|
||||
|
||||
@@ -95,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();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
@@ -16,9 +17,13 @@ import {
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
expectSummaryInSidebar,
|
||||
expectUnpinnableRows,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
openUsageScreenFromIcon,
|
||||
pinRow,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
@@ -93,18 +98,15 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await test.step("a fresh device shows default windows once the Usage item is on", async () => {
|
||||
// The Usage item starts off on every layout; turning it on stores the choice.
|
||||
await expectNoUsageItem(page);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterItemSetting(page, "usage", false);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await qaScreenshot(page, "desktop-settings-sidebar-footer");
|
||||
await page.setViewportSize(COMPACT);
|
||||
// Phones start with the Usage item off; turning it on stores the choice.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await expectFooterItemSetting(page, "usage", true);
|
||||
await qaScreenshot(page, "compact-settings-sidebar-footer");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
@@ -112,8 +114,15 @@ test.describe("Usage item", () => {
|
||||
await qaScreenshot(page, "compact-footer-defaults");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
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 +131,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 +189,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 +225,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 +243,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 () => {
|
||||
@@ -283,26 +302,49 @@ 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 switch turns on the sidebar summary and the pins with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
const claude = screen.getByTestId("usage-report-claude:default");
|
||||
|
||||
await openUsageOptions(page);
|
||||
const toggle = page.getByTestId("usage-show-in-sidebar");
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await toggle.click();
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
|
||||
await openUsageScreenFromIcon(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await qaScreenshot(page, "usage-screen-summary-off");
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await test.step("on: the Usage item and the pins appear", async () => {
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "usage-screen-summary-on");
|
||||
});
|
||||
|
||||
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
|
||||
await setSummaryInSidebar(page, false);
|
||||
await expectNoUsageItem(page);
|
||||
await expectUnpinnableRows(claude);
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("released hosts supply source logos through the client conversion", async ({ page }) => {
|
||||
@@ -310,6 +352,7 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
providerUsageListOnly: true,
|
||||
});
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expectComposerVisible } from "./composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
|
||||
import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports";
|
||||
import { claudeAndCodexReports } from "./usage-sidebar-item";
|
||||
|
||||
// 32,000 of the mock's 128,000-token window.
|
||||
const METER_NAME = "Context window 25% used";
|
||||
|
||||
export type MockAgentSession = Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
|
||||
|
||||
/** An agent a quarter into its context window. */
|
||||
export function seedAgentWithContextWindow(): Promise<MockAgentSession> {
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-usage-",
|
||||
title: "Context window usage e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAgent(page: Page, session: MockAgentSession): Promise<void> {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
export async function reloadAgent(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
/** Wide screens show the meter's details in a tooltip while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 });
|
||||
const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" });
|
||||
await expect(tooltip).toBeVisible();
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowSheet(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
const sheet = contextWindowSheet(page);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function contextWindowSheet(page: Page): Locator {
|
||||
return page.getByRole("dialog", { name: "Context window" });
|
||||
}
|
||||
|
||||
/** One report card; cards have no role of their own. */
|
||||
export function usageCard(surface: Locator, reportId: string): Locator {
|
||||
return surface.getByTestId(`usage-report-${reportId}`);
|
||||
}
|
||||
|
||||
export async function refreshUsageCard(surface: Locator, source: string): Promise<void> {
|
||||
await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectRefreshButtons(surface: Locator, count: number): Promise<void> {
|
||||
await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count);
|
||||
}
|
||||
|
||||
/** A report on the agent's own login, which is not the host's default one. */
|
||||
export function onWorkLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return onLogin(report, "work");
|
||||
}
|
||||
|
||||
/** A report on the login an agent resumed under. */
|
||||
export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return onLogin(report, "personal");
|
||||
}
|
||||
|
||||
function onLogin(report: UsageReportEntry, login: string): UsageReportEntry {
|
||||
return {
|
||||
...report,
|
||||
id: `${report.sourceId}:${login}`,
|
||||
account: { label: `${login}@example.com` },
|
||||
};
|
||||
}
|
||||
|
||||
export function expiredLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return {
|
||||
...report,
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Holds a stream at a point until the test opens it, like a slow source. */
|
||||
export function gate(): { promise: Promise<void>; open: () => void } {
|
||||
let open!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
export interface AgentUsageScript {
|
||||
/** The answer to the next agent usage request or report Refresh. */
|
||||
answerNext(response: UsageListResponse): void;
|
||||
/** From the next reload the host no longer reports usage. */
|
||||
stopReportingUsage(): void;
|
||||
/** The agent of each agent usage request, in order. */
|
||||
agentRequests(): string[];
|
||||
/** Each report Refresh, by report ID. */
|
||||
refreshedReports(): string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripts the host's usage: the host's own list is Claude and Codex on its default login; each
|
||||
* agent request and each Refresh takes the next scripted answer.
|
||||
*/
|
||||
export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
let supported = true;
|
||||
const answers: UsageListResponse[] = [];
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSupported: () => supported,
|
||||
lists: [
|
||||
(request) => {
|
||||
if (!request.agentId && !request.reportIds) return claudeAndCodexReports();
|
||||
const next = answers.shift();
|
||||
if (!next) throw new Error("The test scripts every agent usage request.");
|
||||
return next;
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
answerNext: (response) => answers.push(response),
|
||||
stopReportingUsage: () => {
|
||||
supported = false;
|
||||
},
|
||||
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
||||
refreshedReports: () =>
|
||||
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
export async function qaScreenshot(page: Page, name: string): Promise<void> {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let the sheet and tooltip animations settle.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -453,3 +453,21 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
const scrollBody = page.getByTestId("settings-sidebar-scroll-body");
|
||||
await expect(scrollBody).toBeVisible();
|
||||
await scrollBody.hover();
|
||||
await page.mouse.wheel(0, 10_000);
|
||||
const readOffset = () => scrollBody.evaluate((node) => node.scrollTop);
|
||||
await expect
|
||||
.poll(readOffset, { message: "settings sidebar must overflow for this check" })
|
||||
.toBeGreaterThan(0);
|
||||
await expect.poll(async () => (await readOffset()) - (await readOffset())).toBe(0);
|
||||
return readOffset();
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
|
||||
const scrollBody = page.getByTestId("settings-sidebar-scroll-body");
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
|
||||
}
|
||||
|
||||
@@ -197,18 +197,29 @@ export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
|
||||
.click();
|
||||
}
|
||||
|
||||
function footerItemSwitch(page: Page, key: string): Locator {
|
||||
return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
}
|
||||
|
||||
export async function setFooterItemVisible(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
const toggle = footerItemSwitch(page, key);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
/** Whether Settings > Sidebar shows a footer item as on. */
|
||||
export async function expectFooterItemSetting(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
|
||||
}
|
||||
@@ -225,6 +236,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 +254,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" });
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
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 type { Page } from "@playwright/test";
|
||||
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";
|
||||
|
||||
@@ -61,6 +62,73 @@ export async function refreshLoginUsage(page: Page) {
|
||||
export async function hoverUsageWindow(page: Page, window: string) {
|
||||
await page
|
||||
.getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) })
|
||||
.getByTestId("usage-pin-glyph-unpinned")
|
||||
.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: (scope) => discover(scope, {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/,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||
export interface UsageListRequest {
|
||||
forceRefresh: boolean;
|
||||
reportIds?: string[];
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
@@ -19,15 +20,25 @@ interface UsageReportsFixtureOptions {
|
||||
* `fetchedAt`, or a different answer to a forced refresh).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
|
||||
/** False simulates a host with no usage reporting capability. */
|
||||
usageSupported?: boolean;
|
||||
/**
|
||||
* False simulates a host with no usage reporting capability. A function is read each time the
|
||||
* app connects, so a reload can switch it.
|
||||
*/
|
||||
usageSupported?: boolean | (() => boolean);
|
||||
/** Released hosts expose provider.usage.list with no source icons. */
|
||||
providerUsageListOnly?: boolean;
|
||||
/** The host daemon's port; defaults to the E2E daemon. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
/**
|
||||
* A list of reports, a request error, or a stream: reports sent in order, where a promise holds
|
||||
* the rest of the stream and the response until it settles, like a slow source.
|
||||
*/
|
||||
export type UsageListResponse =
|
||||
| UsageReportEntry[]
|
||||
| { error: string }
|
||||
| { stream: Array<UsageReportEntry | Promise<unknown>> };
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -114,6 +125,8 @@ export async function installUsageReportsFixture(
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const isUsageSupported = () =>
|
||||
typeof usageSupported === "function" ? usageSupported() : usageSupported;
|
||||
const providerUsageListOnly = options.providerUsageListOnly ?? false;
|
||||
const requestType = providerUsageListOnly
|
||||
? "provider.usage.list.request"
|
||||
@@ -131,6 +144,7 @@ export async function installUsageReportsFixture(
|
||||
const listRequest: UsageListRequest = {
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : undefined,
|
||||
};
|
||||
listRequests.push(listRequest);
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
@@ -139,22 +153,51 @@ export async function installUsageReportsFixture(
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType,
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
message: providerUsageListOnly
|
||||
? {
|
||||
type: "rpc_error",
|
||||
payload: { requestId, requestType, error: response.error, code: "transport" },
|
||||
}
|
||||
: {
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId, error: response.error },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
const sendUpdate = (report: UsageReportEntry) =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.update", payload: { requestId, report } },
|
||||
}),
|
||||
);
|
||||
if ("stream" in response) {
|
||||
void (async () => {
|
||||
for (const item of response.stream) {
|
||||
if (item instanceof Promise) await item;
|
||||
else sendUpdate(item);
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId, error: null },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
if (!providerUsageListOnly) {
|
||||
for (const report of reports) sendUpdate(report);
|
||||
}
|
||||
const reply = providerUsageListOnly
|
||||
? {
|
||||
type: "provider.usage.list.response",
|
||||
@@ -175,7 +218,7 @@ export async function installUsageReportsFixture(
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { type: "usage.list_reports.response", payload: { requestId, reports } };
|
||||
: { type: "usage.list_reports.response", payload: { requestId, error: null } };
|
||||
ws.send(JSON.stringify({ type: "session", message: reply }));
|
||||
listCounter.increment();
|
||||
return;
|
||||
@@ -186,7 +229,7 @@ export async function installUsageReportsFixture(
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, usageSupported, providerUsageListOnly)
|
||||
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
|
||||
@@ -168,6 +168,19 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
|
||||
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
|
||||
* but buttons, so no row presses or highlights on hover.
|
||||
*/
|
||||
export async function expectUnpinnableRows(card: Locator): Promise<void> {
|
||||
await expect(card.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
|
||||
const focusable = await card
|
||||
.locator("[tabindex]")
|
||||
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
|
||||
expect(focusable).toBe(0);
|
||||
}
|
||||
|
||||
export async function togglePin(scope: Locator, source: string, window: string) {
|
||||
const row = pinRow(scope, source, window);
|
||||
const pinned = await row.isChecked();
|
||||
@@ -175,21 +188,46 @@ 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. */
|
||||
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
|
||||
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
|
||||
await expectOnUsageScreen(page);
|
||||
}
|
||||
|
||||
function summaryInSidebarSwitch(page: Page): Locator {
|
||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
||||
}
|
||||
|
||||
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
|
||||
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await summaryInSidebarSwitch(page).click();
|
||||
await expectSummaryInSidebar(page, on);
|
||||
}
|
||||
|
||||
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
|
||||
}
|
||||
|
||||
/** 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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentUsage } from "@/usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowDetailsProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
percentage: number;
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
sessionCost: string | null;
|
||||
/** The tooltip titles itself; the sheet's header carries the title instead. */
|
||||
showTitle: boolean;
|
||||
/** Only the sheet can be pressed, so only its usage cards have a Refresh button. */
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the context window meter opens: how full the window is, then the usage of the agent's
|
||||
* account. The wide tooltip and the compact sheet both render it.
|
||||
*/
|
||||
export function ContextWindowDetails({
|
||||
serverId,
|
||||
agentId,
|
||||
percentage,
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
sessionCost,
|
||||
showTitle,
|
||||
refreshable,
|
||||
}: ContextWindowDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<View style={styles.summary}>
|
||||
{showTitle ? <Text style={styles.title}>{t("contextWindow.title")}</Text> : null}
|
||||
<Text style={styles.text}>{t("contextWindow.used", { percentage })}</Text>
|
||||
<Text style={styles.detail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{sessionCost ? (
|
||||
<Text style={styles.detail}>{t("contextWindow.sessionCost", { cost: sessionCost })}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<AgentUsage serverId={serverId} agentId={agentId} refreshable={refreshable} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
summary: {
|
||||
gap: theme.spacing[1.5],
|
||||
minWidth: 200,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostReportsUsage } from "@/usage";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
maxTokens: number | null;
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
@@ -91,6 +97,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({
|
||||
serverId,
|
||||
agentId,
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
@@ -100,6 +108,15 @@ export function ContextWindowMeter({
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
const popoverWidth = Math.min(360, width - 24);
|
||||
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const openSheet = useCallback(() => setIsSheetOpen(true), []);
|
||||
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
|
||||
const percentage =
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
@@ -142,6 +159,71 @@ export function ContextWindowMeter({
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
const ring = (
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
const percentageLabel = showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage });
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
onPress={openSheet}
|
||||
>
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
<ContextWindowSheet open={isSheetOpen} onClose={closeSheet}>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle={false}
|
||||
refreshable
|
||||
/>
|
||||
</ContextWindowSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
@@ -149,62 +231,30 @@ export function ContextWindowMeter({
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("contextWindow.accessibility", {
|
||||
percentage: roundedPercentage,
|
||||
})}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null}
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8} testID="context-window-meter-tooltip">
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
|
||||
<Text style={styles.tooltipText}>
|
||||
{t("contextWindow.used", { percentage: roundedPercentage })}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{formattedSessionCost ? (
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
offset={8}
|
||||
maxWidth={showsUsage ? popoverWidth : undefined}
|
||||
style={showsUsage ? [styles.popover, { width: popoverWidth }] : undefined}
|
||||
testID="context-window-meter-tooltip"
|
||||
>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle
|
||||
refreshable={false}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -237,22 +287,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1.5],
|
||||
minWidth: 200,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
popover: { padding: theme.spacing[4], gap: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SheetHeaderView, SHEET_HORIZONTAL_PADDING_SCALE } from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
|
||||
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
|
||||
// track, so the sheet is wrapped rather than reading the theme through a hook.
|
||||
// See docs/unistyles.md.
|
||||
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
|
||||
backgroundStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
handleIndicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
}));
|
||||
|
||||
/**
|
||||
* The context window meter's details on compact screens, in a sheet as tall as its content.
|
||||
* Gorhom mounts the content only while the sheet is presented, so whatever it fetches stops once
|
||||
* the sheet closes.
|
||||
*/
|
||||
export function ContextWindowSheet({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { height } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const header = useMemo(() => ({ title: t("contextWindow.title") }), [t]);
|
||||
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
onClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
// Content-sized rather than fixed snap points: the usage cards stream in one at a time.
|
||||
enableDynamicSizing
|
||||
maxDynamicContentSize={height * 0.8}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={scrollContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View role="dialog" aria-label={header.title}>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{/* The inset sits on a View: a themed contentContainerStyle resolves to nothing on web. */}
|
||||
<View style={styles.body}>{children}</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: {
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -397,18 +397,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 +713,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}
|
||||
|
||||
@@ -523,6 +523,7 @@ export function TooltipContent({
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
role="tooltip"
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
@@ -549,6 +550,7 @@ export function TooltipContent({
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
role="tooltip"
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
|
||||
@@ -279,6 +279,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
}
|
||||
|
||||
function renderContextWindowMeter(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
contextWindowMaxTokens: number | null,
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
@@ -292,6 +294,8 @@ function renderContextWindowMeter(
|
||||
}
|
||||
return (
|
||||
<ContextWindowMeter
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
@@ -2076,6 +2080,8 @@ function ComposerContentImpl({
|
||||
const contextWindowMeter = useMemo(
|
||||
() =>
|
||||
renderContextWindowMeter(
|
||||
serverId,
|
||||
agentId,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
@@ -2084,6 +2090,8 @@ function ComposerContentImpl({
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
[
|
||||
serverId,
|
||||
agentId,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user