diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d1957d88e7..bbadebf2b6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ Release notes for OpenCode V2, newest first. +## v2.2.2 — 2026-10-29 + +### Highlights + +- **Image Generation by Default**: When a connected provider supports image models, agents can now use `experimental_image_generate` without additional configuration. Set its preferred model through `experimental.image_generate.model`. ([#60258](https://github.com/anomalyco/opencode/pull/60258)) +- **Faster Worktree Selection**: On the new-session screen, click the current Git branch to open that project’s “Run session in” worktree picker directly. ([#60263](https://github.com/anomalyco/opencode/pull/60263)) +- **Unified Quotes and Annotations**: Timeline quotes, terminal quotes, and browser annotations now share a compact optional-comment editor, while source highlights remain attached to local drafts. ([#60164](https://github.com/anomalyco/opencode/pull/60164)) + +### Core + +#### Improvements + +- Enabled `experimental_image_generate` by default when an image-capable provider is connected. ([#60258](https://github.com/anomalyco/opencode/pull/60258)) +- Recorded the start time of each session’s latest execution. + +#### Bug fixes + +- Rejected persistent terminal requests whose working directory no longer exists. +- Preserved media when sessions are forked or imported. +- Associated image-generation notices with the correct background job. + +### TUI + +#### Bug fixes + +- Preserved captured attachments and their original source paths. Thanks [@OpeOginni](https://github.com/OpeOginni). ([#60222](https://github.com/anomalyco/opencode/pull/60222)) +- Prevented middle-truncated paths from overflowing at very narrow terminal widths. +- Improved Go usage-limit messages with the reset time and account page link. +- Correctly typed and displayed Go and ChatGPT usage-limit errors. + +### Desktop + +#### Improvements + +- Made the Git branch on the new-session screen open the current project’s worktree picker directly. ([#60263](https://github.com/anomalyco/opencode/pull/60263)) +- Unified timeline quotes, terminal quotes, and browser annotations around one optional-comment workflow. ([#60164](https://github.com/anomalyco/opencode/pull/60164)) +- Restored classic question cards by default and added **Settings → Experimental → New question UI** for the redesigned keyboard-oriented dock. ([#59723](https://github.com/anomalyco/opencode/pull/59723)) +- Added a side-panel tab menu and kept close buttons visible when tabs overflow. +- Moved user-message actions into a compact menu. +- Redesigned the active todo pill to show the current task and concurrent work more clearly. +- Added consistent styling for searchable dialogs and composer pills. + +#### Bug fixes + +- Stopped queued prompts from dropping frames as their content grows. +- Counted queued quotes and browser annotations as attachments. +- Reported failures to persist composer attachments through the normal error UI. +- Made Markdown fragment links scroll to the referenced heading. +- Kept each tab’s timeline state when switching directories. +- Ordered the inbox’s **Working** shelf by execution start time. +- Fixed side-panel launchers and automatic closing when the final tab is removed. +- Focused the matching navigation arrow while reviewing changes. +- Preserved pending model labels only when a queued prompt actually changes models. +- Added reset details, an account link, and a composer tray for Go usage limits. + +### SDK + +#### Improvements + +- Added `if` and `until` inputs for `client.experimental.schedule.create`, plus schedule check results and output fields. ([#60137](https://github.com/anomalyco/opencode/pull/60137)) +- Exposed `SessionInfo.time.busy` for the latest execution’s start time. +- Added optional source metadata to prompt file attachments. + +### Extensions + +#### Improvements + +- Added optional `title` and `subtitle` fields to composer notes, source-relative quote positioning, and focus options for composer integrations. ([#60164](https://github.com/anomalyco/opencode/pull/60164)) + +#### Bug fixes + +- Corrected visible flowchart edges connected through Merman subgraphs. + ## v2.2.1 — 2026-10-29 ### Highlights diff --git a/bun.lock b/bun.lock index 7bfa22390cc2..0e3fa06fa40d 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/ai": { "name": "@opencode/ai", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "workspace:*", @@ -66,7 +66,7 @@ }, "packages/app": { "name": "@opencode/app", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -135,7 +135,7 @@ }, "packages/cli": { "name": "shuvcode", - "version": "2.2.1-shuv.1", + "version": "2.2.2-shuv.1", "bin": { "shuvcode": "./bin/shuvcode.mjs", }, @@ -144,9 +144,9 @@ "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "catalog:", - "@opencode-ai/pty": "0.3.1", "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", + "@opencode/pty": "0.3.3", "@opencode/schema": "workspace:*", "@opencode/server": "workspace:*", "@opencode/tui": "workspace:*", @@ -201,7 +201,7 @@ }, "packages/client": { "name": "@opencode/client", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/protocol": "workspace:*", "@opencode/schema": "workspace:*", @@ -227,7 +227,7 @@ }, "packages/codemode": { "name": "@opencode/codemode", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -240,7 +240,7 @@ }, "packages/console/app": { "name": "@opencode/console-app", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -278,7 +278,7 @@ }, "packages/core": { "name": "@opencode/core", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ai-sdk/provider": "3.0.19", "@ai-sdk/provider-utils": "4.0.62", @@ -287,11 +287,11 @@ "@lydell/node-pty": "catalog:", "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/core": "2.3.1", - "@opencode-ai/pty": "0.3.1", "@opencode/ai": "workspace:*", "@opencode/codemode": "workspace:*", "@opencode/plugin": "workspace:*", "@opencode/plugin-browser": "workspace:*", + "@opencode/pty": "0.3.3", "@opencode/schema": "workspace:*", "@opencode/util": "workspace:*", "@parcel/watcher": "2.5.1", @@ -345,7 +345,7 @@ }, "packages/desktop": { "name": "@opencode/desktop", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@zip.js/zip.js": "2.7.62", "electron-context-menu": "5.0.0", @@ -393,7 +393,7 @@ }, "packages/function": { "name": "@opencode/function", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -427,7 +427,7 @@ }, "packages/gui-plugins": { "name": "@opencode/gui-plugins", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", @@ -474,7 +474,7 @@ }, "packages/http-recorder": { "name": "@opencode/http-recorder", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/platform-node-shared": "catalog:", }, @@ -493,7 +493,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode/httpapi-codegen", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -506,7 +506,7 @@ }, "packages/latex": { "name": "@opencode/latex", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -520,7 +520,7 @@ }, "packages/merman": { "name": "@opencode/merman", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -535,7 +535,7 @@ }, "packages/patchmark": { "name": "@opencode/patchmark", - "version": "2.2.1", + "version": "2.2.2", "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", @@ -548,7 +548,7 @@ }, "packages/plugin": { "name": "@opencode/plugin", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "workspace:*", @@ -593,7 +593,7 @@ }, "packages/plugin-browser": { "name": "@opencode/plugin-browser", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/plugin": "workspace:*", "@opencode/schema": "workspace:*", @@ -608,7 +608,7 @@ }, "packages/plugins/figma": { "name": "@opencode/plugin-figma", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "gray-matter": "4.0.3", }, @@ -638,7 +638,7 @@ }, "packages/protocol": { "name": "@opencode/protocol", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/schema": "workspace:*", "effect": "catalog:", @@ -668,7 +668,7 @@ }, "packages/schema": { "name": "@opencode/schema", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@standard-schema/spec": "catalog:", "effect": "catalog:", @@ -693,7 +693,7 @@ }, "packages/sdk": { "name": "@opencode/sdk", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -714,7 +714,7 @@ }, "packages/server": { "name": "@opencode/server", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", @@ -726,9 +726,9 @@ "effect": "catalog:", }, "devDependencies": { - "@opencode-ai/pty": "0.3.1", "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", + "@opencode/pty": "0.3.3", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native": "catalog:", @@ -737,7 +737,7 @@ }, "packages/session-ui": { "name": "@opencode/session-ui", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -769,7 +769,7 @@ }, "packages/simulation": { "name": "@opencode/simulation", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/ai": "workspace:*", "@opencode/core": "workspace:*", @@ -789,7 +789,7 @@ }, "packages/stats/app": { "name": "@opencode/stats-app", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -815,7 +815,7 @@ }, "packages/stats/core": { "name": "@opencode/stats-core", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", @@ -833,7 +833,7 @@ }, "packages/stats/server": { "name": "@opencode/stats-server", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/platform-node": "catalog:", "@opencode/stats-core": "workspace:*", @@ -868,7 +868,7 @@ }, "packages/theme": { "name": "@opencode/theme", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opentui/core": "catalog:", "effect": "catalog:", @@ -882,7 +882,7 @@ }, "packages/tui": { "name": "@opencode/tui", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -918,7 +918,7 @@ }, "packages/ui": { "name": "@opencode/ui", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -953,7 +953,7 @@ }, "packages/util": { "name": "@opencode/util", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -992,7 +992,7 @@ }, "packages/web": { "name": "@opencode/web", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@astrojs/cloudflare": "14.1.4", "@astrojs/markdown-remark": "7.3.2", @@ -1044,7 +1044,7 @@ }, "services/update": { "name": "@opencode/update", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "jose": "6.0.11", "semver": "catalog:", @@ -1116,10 +1116,10 @@ "@astrojs/starlight": "0.41.11", "@cloudflare/workers-types": "4.20251008.0", "@corvu/drawer": "0.2.4", - "@effect/opentelemetry": "4.0.1", - "@effect/platform-node": "4.0.1", - "@effect/platform-node-shared": "4.0.1", - "@effect/sql-sqlite-bun": "4.0.1", + "@effect/opentelemetry": "4.0.6", + "@effect/platform-node": "4.0.6", + "@effect/platform-node-shared": "4.0.6", + "@effect/sql-sqlite-bun": "4.0.6", "@kobalte/core": "0.13.13", "@lydell/node-pty": "1.2.0-beta.12", "@npmcli/arborist": "9.4.0", @@ -1156,7 +1156,7 @@ "dompurify": "3.4.17", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-169397b", - "effect": "4.0.1", + "effect": "4.0.6", "fast-check": "4.9.0", "fuzzysort": "3.1.0", "get-east-asian-width": "1.6.0", @@ -1586,13 +1586,13 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.1" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-MZ7M1a1dxMqTqeNwIGSECjO7RrReEgeEwHo8BnbOqP+8IfVpdiJi/iPCmkFVWlAxanMl4qhsq69e0Hkul9t+bA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.6", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.6" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-xhLR1EN4ZNaKM7Bx59SCFiRNduFzgH8LZXDrCpPN8MpQ93V0GQcFHTBah5T3EXO2v+5HTEacdTgetIA0Creqvw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.1", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.1", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.1", "redis": ">=5.0.0 <7.0.0" } }, "sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ=="], + "@effect/platform-node": ["@effect/platform-node@4.0.6", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.6", "undici": "^8.12.2" }, "peerDependencies": { "effect": "^4.0.6", "redis": ">=5.0.0 <7.0.0" } }, "sha512-gXvGHAy0/8PZbWvMMguloDUyeSP+rzYEo3FSEqUuGduxo9ORcGioDxPI+GOLCIFfam7VuNkmSUxBOiRMIrMrBw=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.1", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.6", "", { "dependencies": { "@types/ws": "^8.18.2", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.6" } }, "sha512-0PhyC4uG30xGtsajnxjkcBWfTjNRzUh4ZKl1Kvo/MNyCTb+W2O+fBzf7wCRCoqt+nLudiq4V7vX40Q8XIBuMaQ=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-cBP2gD4KRuci02W1t/jzbrsk9spjiy45d7Z5i95nAnYaUXjU4JFgLa9Dxbumg5NUgzoK7qOdIeO0fULxEhK0LA=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.6", "", { "peerDependencies": { "effect": "^4.0.6" } }, "sha512-IUf0d6NrrG+m/yKqziGyE9xQO5cOPzyDcb98S39OpdQMKHBZjVtx+TN+nz3b8Spc6AvfKjFYTcvXSjSzgjDRug=="], "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="], @@ -1938,24 +1938,6 @@ "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="], - "@opencode-ai/pty": ["@opencode-ai/pty@0.3.1", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.3.1", "@opencode-ai/pty-darwin-x64": "0.3.1", "@opencode-ai/pty-linux-arm64-gnu": "0.3.1", "@opencode-ai/pty-linux-arm64-musl": "0.3.1", "@opencode-ai/pty-linux-x64-gnu": "0.3.1", "@opencode-ai/pty-linux-x64-musl": "0.3.1", "@opencode-ai/pty-win32-arm64": "0.3.1", "@opencode-ai/pty-win32-x64": "0.3.1" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-xmxBVI1z/J6Jt3cC5Mlcc2iOxi5cFtPF3q+023sEZJMEE9hBqh7EdXKZesAzAJ2qBWXGEEqWTudnERY7suoAKw=="], - - "@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pn3dqOX9F4HZ9Vzjb9UTm4B0dz5w9c8jBsy8/Dp1WzrLOjg91cjx/LzPqXG7rrzD39ygyxnHZE4694f7lh9kuQ=="], - - "@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-uBzxgFUxfGew7yR60YW5ng8is571AHWBg4MqytwDVAwlmkEaAzIvja/nxD+dWaszOq+PtJIaYnNqf4ybmG1JXg=="], - - "@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/8GK/hfnHa4O5vvVuFOd/SMr07n19dLmoILjsn8BmTBlBEU5oZClssKoeCDiPigFsVuL2cMBTqb2bONuD5SyDg=="], - - "@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jemE95Tw4tVPUpmHGqDtTKlSnxFexZT6+R05esxdT9uiVnflK9mR0fLlgZpsgSDEQIZPywo5scA4wgqZaPIWKw=="], - - "@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DfU49EkQrE/G+HQZmWg7jvwCTdOkYcFvXFts6MNRycnrtm/K7Qq0SKVGYmZC0J2LN4SmgqCUILzmS+rDNkyw8g=="], - - "@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-P+/oP0Xkfjecd/NmD+dwYQE+l/cUjhevONBauzjfOWSqd32ltznEpKqbfJW0RldR+Gztr9sJdkAATjRHURX1RQ=="], - - "@opencode-ai/pty-win32-arm64": ["@opencode-ai/pty-win32-arm64@0.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-HM2t9xYnFU2NYbFowVINKiVzbTdRtnIf/1kPC5lulLcqWf5g+N/O+uXy4dGqErdun49vJ+MP8EydG52whkE/xQ=="], - - "@opencode-ai/pty-win32-x64": ["@opencode-ai/pty-win32-x64@0.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xUpe+TApPY81AII9EXb9MdN2/WyDJB0t3C/vMsSxBQZSvh6mfML7mqt2kK9lQntQn5wz1kmKi05NRr9bHtkzoQ=="], - "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="], "@opencode/ai": ["@opencode/ai@workspace:packages/ai"], @@ -2002,6 +1984,24 @@ "@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"], + "@opencode/pty": ["@opencode/pty@0.3.3", "", { "optionalDependencies": { "@opencode/pty-darwin-arm64": "0.3.3", "@opencode/pty-darwin-x64": "0.3.3", "@opencode/pty-linux-arm64-gnu": "0.3.3", "@opencode/pty-linux-arm64-musl": "0.3.3", "@opencode/pty-linux-x64-gnu": "0.3.3", "@opencode/pty-linux-x64-musl": "0.3.3", "@opencode/pty-win32-arm64": "0.3.3", "@opencode/pty-win32-x64": "0.3.3" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-iIOczK9UvusAshTH+HeBvt6Q4Q5rYjfBCLT4Z7dMrIjZ4cdodmveYI5ge/2+qOM3ZKWXf5uSO2K5DhQIP5Gmxw=="], + + "@opencode/pty-darwin-arm64": ["@opencode/pty-darwin-arm64@0.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1q9sROkkbGQaG2rxyKT6hW/EN1aDUiST8h6WmQdt1dbgvo3WaYDDjaEWmgwhm7Fhhca+E9WruTfxfVBBDm4Ezg=="], + + "@opencode/pty-darwin-x64": ["@opencode/pty-darwin-x64@0.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-dfphCXfoaXGAa1i2P52U7QelcJoANTWimCvuy8xPNPlazrb7ANP+flzQxcy7f6rP/hnbzGLYUMxU0CQocMjwqA=="], + + "@opencode/pty-linux-arm64-gnu": ["@opencode/pty-linux-arm64-gnu@0.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-MyO+3wIkv6hveEY6Jyd7jgk9PRp1J4y+u0Y5FfUg1SPFUhrFTx9yrHXbSDVbAe7mLmMYu8KhRn4+plk8JXZjyg=="], + + "@opencode/pty-linux-arm64-musl": ["@opencode/pty-linux-arm64-musl@0.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-L0+x0xald7PKwYcckE8NOFef/FpGQ2k13iunGlHJFIN1gxDpI1XtZQEjASIe4w3vq1nB49L7SnbghzMxUKNtOg=="], + + "@opencode/pty-linux-x64-gnu": ["@opencode/pty-linux-x64-gnu@0.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-j1nPB/8nwrJm20s5Gz9Q/8qPQyYowkQNXeLUaz2bilvB5L1Wl8OxOSk39WpcEZYp4gkBNsuguNm1zSZ+++RsKQ=="], + + "@opencode/pty-linux-x64-musl": ["@opencode/pty-linux-x64-musl@0.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dNPmBprzTiM0u18YBsvC4hXRwi2/SGDbvaZvyfr9VooaC/49YIkKbz9hOVzIry8Q8uaWvXagCiYSQwIdPIfSeA=="], + + "@opencode/pty-win32-arm64": ["@opencode/pty-win32-arm64@0.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1v2e97XLZIZein/E7WGyfHOcB1S71pgKp7yq6yCe3OtbDtm5WFnp9mQB+jU2ns1d4ATTp2bJauh7rHFsqaJgeA=="], + + "@opencode/pty-win32-x64": ["@opencode/pty-win32-x64@0.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-V7B4i/2gQxqHNBVt9q1EpJhIHX+UgPJxle6+XzAkUgwOo/75ncUUbn0hsqBy/tqVMhDSA4n5BHQ32U+X7aYI0w=="], + "@opencode/schema": ["@opencode/schema@workspace:packages/schema"], "@opencode/script": ["@opencode/script@workspace:packages/script"], @@ -3182,7 +3182,7 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], + "effect": ["effect@4.0.6", "", {}, "sha512-Tc3mkzuLR6t6P9dhLt0D2GrxYtmU5td4XEJEiXCmILz53IRIfJyHsKZWjTA3xEjiwpNTtXBgI4iqYPxMztBDrw=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -4938,6 +4938,8 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@effect/platform-node-shared/@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + "@effect/platform-node-shared/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], diff --git a/bunfig.toml b/bunfig.toml index 820c80aa093a..cdab8e878cf0 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/knobs", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opencode-ai/pty-win32-arm64", "@opencode-ai/pty-win32-x64", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/highlights", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "pele", "@agentclientprotocol/sdk", "opentui-spinner", "bun-types", "typescript@7.1.0-beta"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/knobs", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode/pty", "@opencode/pty-darwin-arm64", "@opencode/pty-darwin-x64", "@opencode/pty-linux-arm64-gnu", "@opencode/pty-linux-arm64-musl", "@opencode/pty-linux-x64-gnu", "@opencode/pty-linux-x64-musl", "@opencode/pty-win32-arm64", "@opencode/pty-win32-x64", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/highlights", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "pele", "@agentclientprotocol/sdk", "opentui-spinner", "bun-types", "typescript@7.1.0-beta"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 451a45067265..73d223e2a904 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode", "description": "AI-powered development tool", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "packageManager": "bun@1.4.4", @@ -45,10 +45,10 @@ "packages/stats/*" ], "catalog": { - "@effect/opentelemetry": "4.0.1", - "@effect/platform-node": "4.0.1", - "@effect/platform-node-shared": "4.0.1", - "@effect/sql-sqlite-bun": "4.0.1", + "@effect/opentelemetry": "4.0.6", + "@effect/platform-node": "4.0.6", + "@effect/platform-node-shared": "4.0.6", + "@effect/sql-sqlite-bun": "4.0.6", "@npmcli/arborist": "9.4.0", "@types/bun": "1.4.3", "@types/cross-spawn": "6.0.6", @@ -79,7 +79,7 @@ "dompurify": "3.4.17", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-169397b", - "effect": "4.0.1", + "effect": "4.0.6", "fast-check": "4.9.0", "cross-spawn": "7.0.6", "hono": "4.13.13", diff --git a/packages/ai/package.json b/packages/ai/package.json index b10e82f8bdfc..9bbb195c745d 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.1", + "version": "2.2.2", "name": "@opencode/ai", "type": "module", "license": "MIT", diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts index 129566b07de5..cfdcf9df3a56 100644 --- a/packages/app/component-tests/browser-pane.spec.ts +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -210,6 +210,7 @@ story("keeps a still of the page under an open dialog instead of blanking the pa story("comments on a picked element over a still of the page", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("combobox", { name: "Picked element scenario" }).selectOption("zoomed") const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true }) await picker.click() await expect(picker).toHaveAttribute("aria-pressed", "true") @@ -223,20 +224,25 @@ story("comments on a picked element over a still of the page", async ({ page }) await expect(picker).toHaveAttribute("aria-pressed", "false") const editor = root.locator('[data-slot="browser-comment-editor"] textarea') await expect(editor).toBeFocused() + await expect(editor).toHaveAttribute("placeholder", "Add an optional comment...") + await expect(editor).toHaveAccessibleName("Comment on this element") await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible() await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary") // The spotlight frames the picked element in surface pixels. - await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "48px") + await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "38.4px") + await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("width", "128px") // The native page hides behind its still so the editor can float over it. await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") await expect(root.locator("#browser-panel img")).toBeVisible() await editor.fill("Make this the primary colour") - await editor.press("Enter") + await root.getByRole("button", { name: "Add element comment", exact: true }).click() await expect(root.getByTestId("fixture-comments")).toHaveText("button.primary @e7: Make this the primary colour") await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0) await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible() await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(root.getByTestId("fixture-comment-metadata")).toContainText('selector \\"main > button.primary\\"') + await expect(root.getByTestId("fixture-comment-metadata")).toContainText("accessible name") // The pane stays mounted when Beta is routed, and its picker then listens to Beta's page. await root.getByRole("button", { name: "Beta", exact: true }).click() @@ -323,29 +329,147 @@ story("keeps a comment draft but drops its ref when the page navigates", async ( ) }) -story("keeps the comment editor and its actions inside the page", async ({ page }) => { +story("grows the compact comment upward without flipping and keeps its actions inside the page", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("combobox", { name: "Picked element scenario" }).selectOption("low") await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click() await root.getByRole("button", { name: "Pick element", exact: true }).click() const editor = root.locator('[data-slot="browser-comment-editor"]') - // As if the user dragged the textarea's resize handle far past the page. - await editor.locator("textarea").evaluate((element) => { - element.style.height = "900px" - }) - // The editor moves once it has measured its new height. + const field = editor.locator("textarea") + await expect(editor).toHaveCSS("width", "320px") + await expect(field).toBeFocused() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + const opening = await editor.boundingBox() + + if (!opening) throw new Error("Missing opening annotation") + await field.fill("A line of feedback\n".repeat(6)) + await expect(field).toHaveCSS("height", "140px") + // The field lays out before ResizeObserver repositions its parent. + await expect + .poll(async () => { + const grown = await editor.boundingBox() + + return !!grown && grown.y < opening.y && Math.abs(grown.y + grown.height - opening.y - opening.height) < 0.5 + }) + .toBe(true) + await field.fill("A line of feedback\n".repeat(30)) + await expect(field).toHaveCSS("height", "160px") + expect(await field.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) await expect .poll(async () => { const surface = await root.locator('[data-component="browser-comment"]').boundingBox() const box = await editor.boundingBox() - return !!surface && !!box && box.y + box.height <= surface.y + surface.height + return !!surface && !!box && box.y >= surface.y && box.y + box.height <= surface.y + surface.height }) .toBe(true) - const submit = editor.getByRole("button", { name: "Comment", exact: true }) + const submit = editor.getByRole("button", { name: "Add element comment", exact: true }) await submit.scrollIntoViewIfNeeded() await expect(submit).toBeInViewport() + // Near the surface's top, growth stops at the opening bottom rather than pushing the card down. + await field.press("Escape") + await root.getByRole("combobox", { name: "Picked element scenario" }).selectOption("default") + await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await expect(field).toBeFocused() + const nearTop = await editor.boundingBox() + + if (!nearTop) throw new Error("Missing near-top annotation") + await field.fill("A line of feedback\n".repeat(30)) + await expect + .poll(() => field.evaluate((element) => element.clientHeight < 160 && element.scrollHeight > element.clientHeight)) + .toBe(true) + await expect + .poll(async () => { + const box = await editor.boundingBox() + + return !!box && Math.abs(box.y + box.height - nearTop.y - nearTop.height) < 0.5 + }) + .toBe(true) + // A narrow, short pane still keeps the compact editor and action within its surface. + await root.locator("#browser-panel").evaluate((element) => { + const container = element.parentElement + + if (!container) throw new Error("Missing pane container") + container.style.width = "280px" + container.style.height = "150px" + }) + await expect(editor).toHaveCSS("width", "262px") + await expect + .poll(async () => { + const surface = await root.locator('[data-component="browser-comment"]').boundingBox() + const box = await editor.boundingBox() + + return !!surface && !!box && box.y >= surface.y && box.y + box.height <= surface.y + surface.height + }) + .toBe(true) + await expect(submit).toBeInViewport() }) +story( + "wraps the selector, attaches elements without comments, and cancels only empty drafts on clickaway", + async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + const scenario = root.getByRole("combobox", { name: "Picked element scenario" }) + const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true }) + const editor = root.locator('[data-slot="browser-comment-editor"]') + const overlay = root.locator('[data-component="browser-comment"]') + await scenario.selectOption("long") + await root.evaluate((element) => element.setAttribute("dir", "rtl")) + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + const preview = editor.locator('[data-slot="line-comment-v2-preview"] [dir="ltr"]') + const selector = `main > section[data-label="إعدادات"] > ${".nested-selector".repeat(24)} > button.primary` + await expect(preview).toHaveText(selector) + await expect(preview).toHaveAttribute("dir", "ltr") + await expect(preview).toHaveCSS("unicode-bidi", "isolate") + await expect(preview).toHaveCSS("-webkit-line-clamp", "2") + await expect(preview).toHaveCSS("height", "40px") + await preview.hover() + await expect(page.getByRole("tooltip")).toHaveText(selector) + await editor.locator("textarea").press("Enter") + await expect(overlay).toHaveCount(0) + const comments = root.getByTestId("fixture-comments").getByRole("listitem") + await expect(comments).toHaveText(["button.primary @e7:"]) + + await scenario.selectOption("default") + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await editor.getByRole("button", { name: "Add element", exact: true }).click() + await expect(overlay).toHaveCount(0) + await expect(comments).toHaveText(["button.primary @e7:", "button.primary @e7:"]) + expect(JSON.parse(await root.getByTestId("fixture-comment-metadata").innerText())).toEqual( + [selector, "main > button.primary"].map((value) => + expect.objectContaining({ + type: "note", + label: "button.primary", + title: value, + subtitle: "Browser annotation", + comment: "", + subject: `the "button.primary" element in browser tab tab_11111111-1111-1111-1111-111111111111 at https://alpha.example/ (role button; accessible name "Save changes"; selector ${JSON.stringify(value)})`, + live: { + href: "tab_11111111-1111-1111-1111-111111111111#e7", + subject: `the "button.primary" element in browser tab tab_11111111-1111-1111-1111-111111111111 at https://alpha.example/ (role button; accessible name "Save changes"; selector ${JSON.stringify(value)}; browser ref @e7, usable as ref in any browser tool including browser.evaluate until the page navigates)`, + }, + }), + ), + ) + + await scenario.selectOption("fallback") + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await expect(preview).toHaveText("button.primary") + await editor.locator("textarea").fill("Keep this feedback") + await overlay.click({ position: { x: 620, y: 290 } }) + await expect(editor.locator("textarea")).toHaveValue("Keep this feedback") + await editor.locator("textarea").fill("") + await overlay.click({ position: { x: 620, y: 290 } }) + await expect(overlay).toHaveCount(0) + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(comments).toHaveText(["button.primary @e7:", "button.primary @e7:"]) + }, +) + story("shows the empty state over a blank native page and restores navigation", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") await root.getByRole("button", { name: "Blank page", exact: true }).click() @@ -569,7 +693,7 @@ story("suggests visited pages and opens the first one that completes the typed a const address = root.getByRole("combobox", { name: "Browser address", exact: true }) await address.click() await address.pressSequentially("localhost") - const options = page.getByRole("option") + const options = page.getByRole("listbox", { name: "Suggestions" }).getByRole("option") await expect(options).toHaveCount(1) await expect(options.first()).toHaveAttribute("aria-selected", "true") // Arrow keys that pick IME candidates leave the suggestions alone. diff --git a/packages/app/component-tests/fork-dialog.spec.ts b/packages/app/component-tests/fork-dialog.spec.ts new file mode 100644 index 000000000000..59e7fc4dbb1b --- /dev/null +++ b/packages/app/component-tests/fork-dialog.spec.ts @@ -0,0 +1,61 @@ +import { expect, story } from "../../stories/playwright/story" + +story.use({ viewport: { width: 1000, height: 400 } }) + +story("fork search anchors filtering and keyboard scrolling", async ({ mount, page }) => { + await mount("app-dialog-fork-session--default") + const dialog = page.getByRole("dialog", { name: "Fork from message", exact: true }) + const input = dialog.getByRole("searchbox", { name: "Search", exact: true }) + const container = page.locator('[data-component="dialog-v2"] [data-slot="dialog-container"]') + + const geometry = () => + container.evaluate((element) => { + const field = element.querySelector('input[type="search"]')!.getBoundingClientRect() + + return { + height: element.getBoundingClientRect().height, + inputTop: field.top, + inputBottom: field.bottom, + instructionTop: element.querySelector('[data-slot="dialog-description"]')!.getBoundingClientRect().top, + } + }) + + await expect(input).toBeFocused() + await expect(dialog.getByRole("option")).toHaveCount(8) + await expect(dialog).toHaveAccessibleDescription("Choose a message to fork from") + await expect(dialog.getByText("Choose a message to fork from", { exact: true })).toBeVisible() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await input.hover() + const initial = await geometry() + expect(initial.instructionTop).toBeGreaterThanOrEqual(initial.inputBottom) + + await input.fill("keyboard navigation") + await expect(dialog.getByRole("option")).toHaveCount(1) + await expect.poll(async () => (await geometry()).height).toBeLessThan(initial.height - 100) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + + await input.clear() + await expect(dialog.getByRole("option")).toHaveCount(8) + const scroll = dialog.locator("[data-scrollable]") + + await expect.poll(() => scroll.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0) + await input.press("ArrowUp") + + const oldest = dialog.getByRole("option", { + name: /^Investigate how the command palette handles keyboard navigation\./, + }) + + await expect(oldest).toHaveAttribute("aria-selected", "true") + await expect(oldest).toBeInViewport() + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + await expect(input).toBeFocused() + await input.press("Enter") + await expect(dialog).toBeHidden() + await expect(page.getByRole("status")).toHaveText("story-message-0") + + await page.getByRole("button", { name: "Open fork session dialog", exact: true }).click() + await expect(input).toBeFocused() + await input.press("Escape") + await expect(dialog).toBeHidden() +}) diff --git a/packages/app/component-tests/line-comment.spec.ts b/packages/app/component-tests/line-comment.spec.ts index b774dbdfb1e6..d8c204d7a0de 100644 --- a/packages/app/component-tests/line-comment.spec.ts +++ b/packages/app/component-tests/line-comment.spec.ts @@ -11,3 +11,49 @@ story("keeps the comment options button pressed while its menu is open", async ( await expect(trigger).toHaveAttribute("data-expanded", "") await expect(trigger).not.toHaveCSS("background-color", rest) }) + +story("autosizes compact comments without native field-sizing", async ({ mount, page }) => { + // Mock only availability; disable native sizing so real layout exercises the JS fallback. + await page.addInitScript(() => { + const supports = CSS.supports.bind(CSS) + + CSS.supports = (property: string, value?: string) => { + if (property === "field-sizing") return false + + return value === undefined ? supports(property) : supports(property, value) + } + }) + + const component = await mount("ui-line-comment--editor", { args: { variant: "compact" } }) + const textarea = component.getByRole("textbox") + + await page.addStyleTag({ + content: '[data-layout="compact"] [data-slot="line-comment-v2-textarea"] { field-sizing: fixed !important; }', + }) + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await expect(textarea).toHaveCSS("height", "20px") + + await textarea.fill("First\nSecond\nThird") + await expect(textarea).toHaveCSS("height", "60px") + + await textarea.clear() + await expect(textarea).toHaveCSS("height", "20px") + + await textarea.fill(Array.from({ length: 20 }, () => "Line").join("\n")) + await expect(textarea).toHaveCSS("height", "160px") + await expect.poll(() => textarea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) +}) + +story("completes compact optional comments with or without text", async ({ mount }) => { + const component = await mount("ui-line-comment--editor", { args: { variant: "compact", allowEmpty: true } }) + const action = component.getByRole("button", { name: "Comment", exact: true }) + const result = component.getByRole("status", { name: "Editor result" }) + + await expect(action).toBeEnabled() + await action.click() + await expect(result).toHaveText("Submitted:") + + await component.getByRole("textbox").fill("Keep this passage") + await action.click() + await expect(result).toHaveText("Submitted: Keep this passage") +}) diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts index af37dc4830ce..c7d07611b7a2 100644 --- a/packages/app/component-tests/session-question-dock.spec.ts +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -2,1275 +2,1440 @@ import type { Locator } from "@playwright/test" import type { MotionKnob } from "@opencode/ui/motion-tuning" import { expect, sourceURL, story } from "../../stories/playwright/story" -story("resizes the dock to fit different answer counts", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-answer-counts") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const next = question.getByRole("button", { name: "Next", exact: true }) - await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() - await expect(next).toBeEnabled() - - const height = () => question.evaluate((element) => element.getBoundingClientRect().height) - const initial = await height() - await next.click() - await expect.poll(height).toBeGreaterThan(initial) - await expect(question.getByRole("checkbox", { name: "Diagnostics", exact: true })).toBeVisible() - - const expanded = await height() - const back = question.getByRole("button", { name: "Back", exact: true }) - await expect(back).toBeEnabled() - await back.click() - await expect.poll(height).toBeLessThan(expanded) - await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible() -}) - -story("selects single answers and toggles multiple answers with number keys", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const wide = question.getByRole("radio", { name: /Wide/ }) - const customRadio = question.getByRole("radio", { name: "Type your own answer…", exact: true }) - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - const next = question.getByRole("button", { name: "Next", exact: true }) - await expect(focused).toBeFocused() - - await focused.press("2") - await expect(wide).toBeChecked() - await expect(focused).not.toBeChecked() - await expect(wide).toBeFocused() - await next.press("1") - await expect(focused).toBeChecked() - await expect(wide).not.toBeChecked() - await focused.press("3") - await expect(customRadio).toBeChecked() - await expect(input).toBeFocused() - await expect(focused).not.toBeChecked() - await expect(wide).not.toBeChecked() - await input.press("Escape") - await expect(customRadio).toBeFocused() - await customRadio.press("2") - await expect(wide).toBeChecked() - await expect(customRadio).not.toBeChecked() - - await next.click() - const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) - const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) - const customCheckbox = question.getByRole("checkbox", { name: "Type your own answer…", exact: true }) - await expect(files).toBeFocused() - await files.press("1") - await expect(files).toBeChecked() - await files.press("2") - await expect(terminal).toBeChecked() - await expect(files).toBeChecked() - await terminal.press("2") - await expect(terminal).not.toBeChecked() - await expect(files).toBeChecked() - - await terminal.press("4") - await expect(customCheckbox).toBeChecked() - await expect(input).toBeFocused() - await expect(files).toBeChecked() - await expect(terminal).not.toBeChecked() - await input.press("Escape") - await expect(customCheckbox).toBeFocused() - await customCheckbox.press("4") - await expect(customCheckbox).not.toBeChecked() - await expect(files).toBeChecked() - await expect(terminal).not.toBeChecked() -}) +story( + "uses the original question cards by default and submits selected and custom answers", + async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const wide = question.getByRole("radio", { name: /Wide/ }) + await expect(focused).toBeFocused() + const shortcuts = question.locator('[data-slot="question-option-shortcut"] [data-slot="keybind-v2-label"]') + await expect(shortcuts).toHaveText(["1", "2", "3"]) + await expect(question.getByText("Select one answer", { exact: true })).toBeVisible() + await page.keyboard.press("ArrowRight") + await expect(wide).toBeFocused() + await page.keyboard.press(" ") + await expect(wide).toBeChecked() + await expect(shortcuts).toHaveText(["1", "2", "3"]) + await expect(focused).not.toBeChecked() + + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + const next = question.getByRole("button", { name: "Next", exact: true }) + const width = await next.evaluate((button) => button.getBoundingClientRect().width) + await next.click() + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + const custom = question.getByRole("checkbox", { name: "Type your own answer…", exact: true }) + await expect(files).toBeFocused() + const submit = question.getByRole("button", { name: "Submit", exact: true }) + expect(await submit.evaluate((button) => button.getBoundingClientRect().width)).toBe(width) + await expect(shortcuts).toHaveText(["1", "2", "3", "4"]) + await files.click() + await terminal.click() + await expect(files).toBeChecked() + await expect(terminal).toBeChecked() + await expect(shortcuts).toHaveText(["1", "2", "3", "4"]) + await terminal.click() + await expect(terminal).not.toBeChecked() + await terminal.click() + await input.fill("Diagnostics") + await expect(custom).toBeChecked() + await input.press("Enter") + await expect(custom).toBeFocused() + await expect(input).toHaveValue("Diagnostics") + await question.getByRole("button", { name: "Back", exact: true }).click() + await expect(wide).toBeChecked() + await next.click() + await expect(files).toBeChecked() + await expect(terminal).toBeChecked() + await expect(custom).toBeChecked() + await submit.click() + await expect(question).toBeHidden() + }, +) -story("moves only the highlight with Up and Down and marks it with Space", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") +story("keeps classic long answer lists inside the panel and scrolls them", async ({ mount, page }) => { + const component = await mount("session-question-switch--long-list") const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const wide = question.getByRole("radio", { name: /Wide/ }) - const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true }) - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - const highlight = question.locator('[data-slot="question-highlight"]') - const clip = () => highlight.evaluate((element) => element.style.clipPath) - await expect(focused).toBeFocused() - await expect(focused).not.toBeChecked() - const first = await clip() - - await page.keyboard.press("ArrowDown") - await expect(wide).toBeFocused() - await expect(wide).not.toBeChecked() - await expect(focused).not.toBeChecked() - await expect(wide).toHaveAttribute("tabindex", "0") - await expect(focused).toHaveAttribute("tabindex", "-1") - await expect.poll(clip).not.toBe(first) - - await page.keyboard.press("ArrowDown") - await expect(custom).toBeFocused() - await expect(custom).not.toBeChecked() - await expect(input).not.toBeFocused() - - await page.keyboard.press("ArrowUp") - await expect(wide).toBeFocused() - await page.keyboard.press(" ") - await expect(wide).toBeChecked() - await expect(wide).toBeFocused() - await expect(question.getByRole("button", { name: "Next", exact: true })).toBeVisible() - - await page.keyboard.press("Home") - await expect(focused).toBeFocused() - await expect(focused).not.toBeChecked() - await expect(wide).toBeChecked() -}) + const first = question.getByRole("checkbox", { name: /All typography/ }) + await expect(first).toBeFocused() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await expect + .poll(() => + question.evaluate((root) => { + const options = root.querySelector('[data-slot="question-options"]')! + const box = root.getBoundingClientRect() -story("shows the highlight only for hover or the arrow keys, never for a click", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const wide = question.getByRole("radio", { name: /Wide/ }) - const highlight = question.locator('[data-slot="question-highlight"]') - const clip = () => highlight.evaluate((element) => element.style.clipPath) - await expect(focused).toBeFocused() - await expect(highlight).toBeHidden() - - await wide.dispatchEvent("click") - await expect(wide).toBeChecked() - await expect(highlight).toBeHidden() - - await page.keyboard.press("1") - await expect(highlight).toBeHidden() - - await wide.hover() - await expect(highlight).toBeVisible() - await page.keyboard.press(" ") - await page.mouse.move(0, 0) - await expect(highlight).toBeHidden() - - await wide.hover() - await expect(highlight).toBeVisible() - const hovered = await clip() - await expect.poll(() => highlight.evaluate((element) => getComputedStyle(element).transitionDuration)).toBe("0s") - - await wide.click() - await expect(wide).toBeChecked() - await expect(highlight).toBeVisible() - expect(await clip()).toBe(hovered) - - await page.mouse.move(0, 0) - await expect(highlight).toBeHidden() - - await focused.click() - await page.mouse.move(0, 0) - await expect(focused).toBeChecked() - await expect(highlight).toBeHidden() - - await page.keyboard.press("ArrowDown") - await expect(highlight).toBeVisible() - await page.mouse.move(0, 0) - await expect(highlight).toBeVisible() - - await focused.hover() - await page.mouse.move(0, 0) - await expect(highlight).toBeHidden() -}) + return { fits: box.top >= 0 && box.bottom <= innerHeight, scrolls: options.scrollHeight > options.clientHeight } + }), + ) + .toEqual({ fits: true, scrolls: true }) -story("Enter picks the highlighted answer and moves on", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const wide = question.getByRole("radio", { name: /Wide/ }) - const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) - const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) - await expect(focused).toBeFocused() - - await page.keyboard.press("ArrowDown") - await page.keyboard.press("Enter") - await expect(files).toBeFocused() - await expect(files).not.toBeChecked() - await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeVisible() - - await page.keyboard.press("ArrowLeft") - await expect(wide).toBeFocused() - await expect(wide).toBeChecked() - await expect(focused).not.toBeChecked() - await page.keyboard.press("ArrowRight") - - await page.keyboard.press("ArrowDown") - await page.keyboard.press(" ") - await expect(terminal).toBeChecked() - await expect(files).not.toBeChecked() - await page.keyboard.press("Enter") - await expect(question).toBeHidden() + await first.press("End") + await expect(question.getByRole("checkbox", { name: "Type your own answer…", exact: true })).toBeFocused() + await expect(question.locator('[data-slot="question-option"][data-custom="true"]')).toBeInViewport({ ratio: 1 }) + await question.getByRole("button", { name: "Next", exact: true }).click() + await expect(question.getByRole("radio", { name: "One pull request", exact: true })).toBeFocused() }) -story("typing on the highlighted custom answer starts it", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true }) - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - await expect(focused).toBeFocused() - await focused.press("1") - await expect(focused).toBeChecked() - - await page.keyboard.press("End") - await expect(custom).toBeFocused() - await expect(custom).not.toBeChecked() - await page.keyboard.press("S") - await expect(input).toBeFocused() - await expect(custom).toBeChecked() - await expect(focused).not.toBeChecked() - await page.keyboard.type("plit") - await expect(input).toHaveValue("Split") - - await page.keyboard.press("Enter") - await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused() - await page.keyboard.press("ArrowLeft") - await expect(custom).toBeChecked() - await expect(input).toHaveValue("Split") -}) +story( + "minimizes and restores classic questions immediately for reduced motion and keyboard actions", + async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + await question.getByRole("button", { name: "Minimize question", exact: true }).press("Enter") + expect( + await question + .locator('[data-slot="question-options"]') + .evaluate((options) => options.getBoundingClientRect().height), + ).toBe(0) + await question.getByRole("button", { name: "Restore question", exact: true }).press("Enter") + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() -story("switches questions with Left and Right outside the text field", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) - const submit = question.getByRole("button", { name: "Submit", exact: true }) - const wide = question.getByRole("radio", { name: /Wide/ }) - await expect(focused).toBeFocused() - await page.keyboard.press("ArrowDown") - await page.keyboard.press(" ") - await expect(wide).toBeChecked() - - await page.keyboard.press("ArrowRight") - await expect(files).toBeFocused() - await expect(submit).toBeVisible() - await page.keyboard.press("ArrowRight") - await expect(submit).toBeVisible() - await expect(files).toBeFocused() - - await page.keyboard.press("ArrowLeft") - await expect(wide).toBeFocused() - await expect(wide).toBeChecked() - - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - await input.focus() - await input.fill("ab") - await page.keyboard.press("ArrowLeft") - await expect(input).toBeFocused() - await expect(focused).toBeVisible() -}) + await page.emulateMedia({ reducedMotion: "reduce" }) + await expect(page.locator("html")).toHaveAttribute("data-motion", "reduce") + await question.getByRole("radio", { name: /Focused/ }).click() -// Presses each key after waiting its number of frames, and samples every frame until the switch is at rest: how visible -// the copies of the question text and of the answers are together, how many outgoing copies there are, how the live -// question text is moved, scaled and blurred, how far the count's last digit is rolled (in digit heights, down -// positive), where the progress row's bar is, and the footer: how visible Back is and whether a click at its middle -// reaches it, how visible the Next and Submit faces are, how visible the fainter of their two words is, and how wide -// the button they share shows. It also keeps the header's text and, for the count's last digit, each digit drawn and -// how visible it is. -const play = (question: Locator, keys: { key: string; after?: number }[]) => - question.evaluate(async (root, keys) => { - const opacity = (el: Element) => { - let value = 1 - - for (let node: Element | null = el; node && node !== root; node = node.parentElement) - value *= Number(getComputedStyle(node).opacity) - - return value - } + const heights = await question.evaluate((root) => { + const options = root.querySelector('[data-slot="question-options"]')! + const before = options.getBoundingClientRect().height + root.querySelector('button[aria-label="Minimize question"]')!.click() + const minimized = options.getBoundingClientRect().height + root.querySelector('button[aria-label="Restore question"]')!.click() - const shown = (slot: string) => - [...root.querySelectorAll(`[data-slot="${slot}"]`)].reduce((sum, el) => sum + opacity(el), 0) + return { before, minimized, restored: options.getBoundingClientRect().height } + }) - const live = () => root.querySelector('[data-slot="question-details"]:not([data-ghost])') + expect(heights.before).toBeGreaterThan(0) + expect(heights.minimized).toBe(0) + expect(heights.restored).toBe(heights.before) + }, +) - const mask = () => - ["question-details-frame", "question-options"].some((slot) => - (root.querySelector(`[data-slot="${slot}"]`)?.style.maskImage ?? "").includes("to right"), - ) +story( + "opts into the new question UI in Experiments without losing answers and remembers the setting", + async ({ mount, page }) => { + const component = await mount("session-question-switch--experiments") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const toggle = component.getByRole("switch", { name: "New question UI", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + await expect(toggle).not.toBeChecked() + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + await input.fill("Split view") + await input.press("Enter") + await expect(question.getByRole("radio", { name: "Type your own answer…", exact: true })).toBeFocused() + + await toggle.press(" ") + await expect(question).toHaveAttribute("data-variant", "experimental") + await expect(question.locator('[data-slot="question-option-key"]')).toHaveText(["1", "2", "3"]) + await expect(input).toHaveValue("Split view") + await expect(question.getByRole("radio", { name: "Type your own answer…", exact: true })).toBeChecked() + + await page.reload() + await expect(toggle).toBeChecked() + await expect(question).toHaveAttribute("data-variant", "experimental") + await question.getByRole("radio", { name: /Wide/ }).click() + await toggle.press(" ") + await expect(question.locator('[data-slot="question-option-box"]')).toHaveCount(3) + await expect(question.getByRole("radio", { name: /Wide/ })).toBeChecked() + await page.reload() + await expect(toggle).not.toBeChecked() + await expect(question.locator('[data-slot="question-option-box"]')).toHaveCount(3) + }, +) - const glyphs = () => - [ - ...root.querySelectorAll( - '[data-component="rolling-number"] [data-slot="rolling-number-digit"]:last-child [data-slot="rolling-number-glyph"]', - ), - ].flatMap((el) => { - const text = el.dataset.leaving ?? el.textContent ?? "" +story.describe("with the new question UI experiment enabled", () => { + story.beforeEach(({ page }) => + page.addInitScript(() => localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: true } }))), + ) - return text === "" ? [] : [{ text, shown: opacity(el) }] - }) + story("resizes the dock to fit different answer counts", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-answer-counts") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const next = question.getByRole("button", { name: "Next", exact: true }) + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + await expect(next).toBeEnabled() + + const height = () => question.evaluate((element) => element.getBoundingClientRect().height) + const initial = await height() + await next.click() + await expect.poll(height).toBeGreaterThan(initial) + await expect(question.getByRole("checkbox", { name: "Diagnostics", exact: true })).toBeVisible() + + const expanded = await height() + const back = question.getByRole("button", { name: "Back", exact: true }) + await expect(back).toBeEnabled() + await back.click() + await expect.poll(height).toBeLessThan(expanded) + await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible() + }) - const roll = () => { - const glyph = root.querySelector( - '[data-component="rolling-number"] [data-slot="rolling-number-digit"]:last-child [data-slot="rolling-number-glyph"]:not([data-leaving])', - ) + story("selects single answers and toggles multiple answers with number keys", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const wide = question.getByRole("radio", { name: /Wide/ }) + const customRadio = question.getByRole("radio", { name: "Type your own answer…", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + const next = question.getByRole("button", { name: "Next", exact: true }) + await expect(focused).toBeFocused() + + await focused.press("2") + await expect(wide).toBeChecked() + await expect(focused).not.toBeChecked() + await expect(wide).toBeFocused() + await next.press("1") + await expect(focused).toBeChecked() + await expect(wide).not.toBeChecked() + await focused.press("3") + await expect(customRadio).toBeChecked() + await expect(input).toBeFocused() + await expect(focused).not.toBeChecked() + await expect(wide).not.toBeChecked() + await input.press("Escape") + await expect(customRadio).toBeFocused() + await customRadio.press("2") + await expect(wide).toBeChecked() + await expect(customRadio).not.toBeChecked() + + await next.click() + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) + const customCheckbox = question.getByRole("checkbox", { name: "Type your own answer…", exact: true }) + await expect(files).toBeFocused() + await files.press("1") + await expect(files).toBeChecked() + await files.press("2") + await expect(terminal).toBeChecked() + await expect(files).toBeChecked() + await terminal.press("2") + await expect(terminal).not.toBeChecked() + await expect(files).toBeChecked() + + await terminal.press("4") + await expect(customCheckbox).toBeChecked() + await expect(input).toBeFocused() + await expect(files).toBeChecked() + await expect(terminal).not.toBeChecked() + await input.press("Escape") + await expect(customCheckbox).toBeFocused() + await customCheckbox.press("4") + await expect(customCheckbox).not.toBeChecked() + await expect(files).toBeChecked() + await expect(terminal).not.toBeChecked() + }) - if (!(glyph instanceof HTMLElement)) return 0 + story("moves only the highlight with Up and Down and marks it with Space", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const wide = question.getByRole("radio", { name: /Wide/ }) + const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + const highlight = question.locator('[data-slot="question-highlight"]') + const clip = () => highlight.evaluate((element) => element.style.clipPath) + await expect(focused).toBeFocused() + await expect(focused).not.toBeChecked() + const first = await clip() - return ( - new DOMMatrix(getComputedStyle(glyph).transform === "none" ? undefined : getComputedStyle(glyph).transform) - .m42 / glyph.getBoundingClientRect().height - ) - } + await page.keyboard.press("ArrowDown") + await expect(wide).toBeFocused() + await expect(wide).not.toBeChecked() + await expect(focused).not.toBeChecked() + await expect(wide).toHaveAttribute("tabindex", "0") + await expect(focused).toHaveAttribute("tabindex", "-1") + await expect.poll(clip).not.toBe(first) - const bar = () => { - const current = root.querySelector('[data-slot="question-progress-current"]') + await page.keyboard.press("ArrowDown") + await expect(custom).toBeFocused() + await expect(custom).not.toBeChecked() + await expect(input).not.toBeFocused() + + await page.keyboard.press("ArrowUp") + await expect(wide).toBeFocused() + await page.keyboard.press(" ") + await expect(wide).toBeChecked() + await expect(wide).toBeFocused() + await expect(question.getByRole("button", { name: "Next", exact: true })).toBeVisible() + + await page.keyboard.press("Home") + await expect(focused).toBeFocused() + await expect(focused).not.toBeChecked() + await expect(wide).toBeChecked() + }) - return current ? current.getBoundingClientRect().left : 0 - } + story("shows the highlight only for the arrow keys, never for hover or a click", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const wide = question.getByRole("radio", { name: /Wide/ }) + const highlight = question.locator('[data-slot="question-highlight"]') + const clip = () => highlight.evaluate((element) => element.style.clipPath) + await expect(focused).toBeFocused() + await expect(highlight).toBeHidden() - const back = root.querySelector('[data-slot="question-footer-back"]')! - const faces = [...root.querySelectorAll('[data-slot="question-advance-face"]')] + await wide.dispatchEvent("click") + await expect(wide).toBeChecked() + await expect(highlight).toBeHidden() - const hit = () => { - const box = back.getBoundingClientRect() + await page.keyboard.press("1") + await expect(highlight).toBeHidden() - return back.contains(document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)) - } + await wide.hover() + await expect(highlight).toBeHidden() + await expect(focused).toBeFocused() - // The faces are cut back from their start edge with `inset(top right bottom left)`. - const width = () => - Math.max( - ...faces.map((face) => { - if (opacity(face) < 0.01 || getComputedStyle(face).visibility === "hidden") return 0 - const inset = /inset\(([^)]*)\)/.exec(face.style.clipPath)?.[1]?.split(/\s+/).map(parseFloat) ?? [] + await wide.click() + await expect(wide).toBeChecked() + await expect(highlight).toBeHidden() - return face.getBoundingClientRect().width - (inset[1] ?? 0) - (inset[3] ?? 0) - }), - ) + await page.mouse.move(0, 0) + await expect(highlight).toBeHidden() - // `click:N` clicks the Nth progress dash, `next` clicks the Next or Submit button in use, and `mod+Enter` presses the - // submit shortcut. - const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform) + await focused.click() + await page.mouse.move(0, 0) + await expect(focused).toBeChecked() + await expect(highlight).toBeHidden() - const press = (key: string) => { - if (key === "next") - return root - .querySelector('[data-slot="question-advance-face"][data-active="true"] button') - ?.click() + await page.keyboard.press("ArrowDown") + await expect(wide).toBeFocused() + await expect(highlight).toBeVisible() + const keyboard = await clip() + await page.mouse.move(0, 0) + await expect(highlight).toBeVisible() - if (key === "mod+Enter") - return (document.activeElement ?? root).dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", metaKey: mac, ctrlKey: !mac, bubbles: true, cancelable: true }), - ) + await focused.hover() + await expect(wide).toBeFocused() + await expect(highlight).toBeVisible() + expect(await clip()).toBe(keyboard) + await question.getByRole("textbox", { name: "Type your own answer…", exact: true }).hover() + await expect(wide).toBeFocused() + expect(await clip()).toBe(keyboard) + await page.mouse.move(0, 0) + await expect(highlight).toBeVisible() + }) - const dash = /^click:(\d+)$/.exec(key)?.[1] + story("Enter picks the highlighted answer and moves on", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const wide = question.getByRole("radio", { name: /Wide/ }) + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) + await expect(focused).toBeFocused() - if (dash) - return root.querySelectorAll('[data-slot="question-progress-segment"]')[Number(dash)]?.click() + await page.keyboard.press("ArrowDown") + await page.keyboard.press("Enter") + await expect(files).toBeFocused() + await expect(files).not.toBeChecked() + await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeVisible() - return (document.activeElement ?? root).dispatchEvent( - new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), - ) - } + await page.keyboard.press("ArrowLeft") + await expect(wide).toBeFocused() + await expect(wide).toBeChecked() + await expect(focused).not.toBeChecked() + await page.keyboard.press("ArrowRight") - const frames: { - text: number - answers: number - ghosts: number - x: number - scale: number - blur: boolean - mask: boolean - digit: number - bar: number - back: number - hit: boolean - next: number - submit: number - width: number - words: number - highlights: number - footer: boolean - animations: number - title: string - glyphs: { text: string; shown: number }[] - }[] = [] - - const queue = [...keys] - let wait = 0 - let still = 0 - - await new Promise((resolve) => { - const tick = () => { - while (queue.length > 0 && wait <= 0) { - const next = queue.shift()! - press(next.key) - wait = queue[0]?.after ?? 0 - } + await page.keyboard.press("ArrowDown") + await page.keyboard.press(" ") + await expect(terminal).toBeChecked() + await expect(files).not.toBeChecked() + await page.keyboard.press("Enter") + await expect(question).toBeHidden() + }) - wait-- - const el = live() - const style = el ? getComputedStyle(el) : undefined - const matrix = new DOMMatrix(style?.transform === "none" ? undefined : style?.transform) - - const frame = { - text: shown("question-details"), - answers: shown("question-options-list"), - ghosts: root.querySelectorAll("[data-ghost]").length, - x: matrix.m41, - scale: matrix.m11, - blur: (style?.filter ?? "none") !== "none", - mask: mask(), - digit: roll(), - bar: bar(), - back: getComputedStyle(back).visibility === "hidden" ? 0 : opacity(back), - hit: hit(), - next: getComputedStyle(faces[0]!).visibility === "hidden" ? 0 : opacity(faces[0]!), - submit: getComputedStyle(faces[1]!).visibility === "hidden" ? 0 : opacity(faces[1]!), - width: width(), - words: Math.min( - ...faces.map((face) => - getComputedStyle(face).visibility === "hidden" - ? 0 - : opacity(face.querySelector('[data-slot="question-advance-label"]')!), - ), - ), - highlights: root.querySelectorAll('[data-slot="question-highlight"]').length, - footer: !!back.getAttribute("style") || faces.some((face) => !!face.getAttribute("style")), - animations: document.getAnimations().length, - title: root.querySelector('[data-slot="question-header-title"]')?.textContent ?? "", - glyphs: glyphs(), - } + story("typing on the highlighted custom answer starts it", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + await expect(focused).toBeFocused() + await focused.press("1") + await expect(focused).toBeChecked() + + await page.keyboard.press("End") + await expect(custom).toBeFocused() + await expect(custom).not.toBeChecked() + await page.keyboard.press("S") + await expect(input).toBeFocused() + await expect(custom).toBeChecked() + await expect(focused).not.toBeChecked() + await page.keyboard.type("plit") + await expect(input).toHaveValue("Split") + + await page.keyboard.press("Enter") + await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused() + await page.keyboard.press("ArrowLeft") + await expect(custom).toBeChecked() + await expect(input).toHaveValue("Split") + }) - frames.push(frame) + story("switches questions with Left and Right outside the text field", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + const submit = question.getByRole("button", { name: "Submit", exact: true }) + const wide = question.getByRole("radio", { name: /Wide/ }) + await expect(focused).toBeFocused() + await page.keyboard.press("ArrowDown") + await page.keyboard.press(" ") + await expect(wide).toBeChecked() + + await page.keyboard.press("ArrowRight") + await expect(files).toBeFocused() + await expect(submit).toBeVisible() + await page.keyboard.press("ArrowRight") + await expect(submit).toBeVisible() + await expect(files).toBeFocused() + + await page.keyboard.press("ArrowLeft") + await expect(wide).toBeFocused() + await expect(wide).toBeChecked() + + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + await input.focus() + await input.fill("ab") + await page.keyboard.press("ArrowLeft") + await expect(input).toBeFocused() + await expect(focused).toBeVisible() + }) - const resting = - queue.length === 0 && - frame.ghosts === 0 && - style?.transform === "none" && - !frame.blur && - !frame.mask && - frame.digit === 0 && - !frame.footer && - frame.bar === frames.at(-2)?.bar + // Presses each key after waiting its number of frames, and samples every frame until the switch is at rest: how visible + // the copies of the question text and of the answers are together, how many outgoing copies there are, how the live + // question text is moved, scaled and blurred, how far the count's last digit is rolled (in digit heights, down + // positive), where the progress row's bar is, and the footer: how visible Back is and whether a click at its middle + // reaches it, how visible the Next and Submit faces are, how visible the fainter of their two words is, and how wide + // the button they share shows. It also keeps the header's text and, for the count's last digit, each digit drawn and + // how visible it is. + const play = (question: Locator, keys: { key: string; after?: number }[]) => + question.evaluate(async (root, keys) => { + const opacity = (el: Element) => { + let value = 1 + + for (let node: Element | null = el; node && node !== root; node = node.parentElement) + value *= Number(getComputedStyle(node).opacity) + + return value + } - still = resting ? still + 1 : 0 + const shown = (slot: string) => + [...root.querySelectorAll(`[data-slot="${slot}"]`)].reduce((sum, el) => sum + opacity(el), 0) - if (still >= 3 || frames.length >= 240) return resolve() - requestAnimationFrame(tick) - } + const live = () => root.querySelector('[data-slot="question-details"]:not([data-ghost])') - requestAnimationFrame(tick) - }) + const mask = () => + ["question-details-frame", "question-options"].some((slot) => + (root.querySelector(`[data-slot="${slot}"]`)?.style.maskImage ?? "").includes("to right"), + ) - return frames - }, keys) + const glyphs = () => + [ + ...root.querySelectorAll( + '[data-component="rolling-number"] [data-slot="rolling-number-digit"]:last-child [data-slot="rolling-number-glyph"]', + ), + ].flatMap((el) => { + const text = el.dataset.leaving ?? el.textContent ?? "" -// The dock's motion is off unless its knob is on, so these turn it on before the dock loads. -story.describe("with the question dock's motion on", () => { - story.beforeEach(({ page }) => - page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))), - ) + return text === "" ? [] : [{ text, shown: opacity(el) }] + }) - story("keeps a question on screen while switching", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + const roll = () => { + const glyph = root.querySelector( + '[data-component="rolling-number"] [data-slot="rolling-number-digit"]:last-child [data-slot="rolling-number-glyph"]:not([data-leaving])', + ) - const forward = await play(question, [{ key: "ArrowRight" }]) - await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeVisible() - const backward = await play(question, [{ key: "ArrowLeft" }]) - await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible() - await expect(question.locator("[data-ghost]")).toHaveCount(0) + if (!(glyph instanceof HTMLElement)) return 0 - for (const frames of [forward, backward]) { - expect(frames.length).toBeLessThan(240) - expect(Math.min(...frames.map((frame) => frame.text))).toBeGreaterThanOrEqual(0.9) - expect(Math.min(...frames.map((frame) => frame.answers))).toBeGreaterThanOrEqual(0.9) - expect(frames.at(-1)).toMatchObject({ x: 0, scale: 1, blur: false }) - } + return ( + new DOMMatrix(getComputedStyle(glyph).transform === "none" ? undefined : getComputedStyle(glyph).transform) + .m42 / glyph.getBoundingClientRect().height + ) + } - expect(Math.min(...forward.map((frame) => frame.scale))).toBeLessThan(1) - expect(forward.some((frame) => frame.blur)).toBe(true) - }) + const bar = () => { + const current = root.querySelector('[data-slot="question-progress-current"]') - story("switches questions without a second answer highlight", async ({ mount, page }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const highlight = question.locator('[data-slot="question-highlight"]') - await expect(question.getByRole("radio").first()).toBeFocused() - await page.keyboard.press("ArrowDown") - await expect(highlight).toBeVisible() + return current ? current.getBoundingClientRect().left : 0 + } - // The leaving copy of the answers carries no highlight, so only the new question's answer is ever highlighted. - const frames = await play(question, [{ key: "ArrowRight" }]) - expect(frames.some((frame) => frame.ghosts > 0)).toBe(true) - expect(frames.every((frame) => frame.highlights === 1)).toBe(true) - }) + const back = root.querySelector('[data-slot="question-footer-back"]')! + const faces = [...root.querySelectorAll('[data-slot="question-advance-face"]')] - story("keeps one outgoing copy and a visible question through rapid switches", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const options = question.locator('[data-slot="question-options"]') - await expect(question.getByRole("radio").first()).toBeFocused() - // From the middle question, so every run can go two either way and comes back. - await play(question, [{ key: "ArrowRight" }, { key: "ArrowRight", after: 30 }]) - - const runs = [ - ["ArrowRight", "ArrowLeft"], - ["ArrowLeft", "ArrowRight"], - ["ArrowRight", "ArrowRight", "ArrowLeft", "ArrowLeft"], - ["ArrowLeft", "ArrowLeft", "ArrowRight", "ArrowRight"], - ] - - for (const run of runs) - for (const after of [0, 1, 2, 4]) { - const frames = await play( - question, - run.map((key) => ({ key, after })), - ) + const hit = () => { + const box = back.getBoundingClientRect() - const label = `${run.join(" ")} every ${after} frames` - expect(frames.length, label).toBeLessThan(240) - expect(Math.min(...frames.map((frame) => frame.text)), label).toBeGreaterThanOrEqual(0.5) - expect(Math.min(...frames.map((frame) => frame.answers)), label).toBeGreaterThanOrEqual(0.5) - expect(Math.max(...frames.map((frame) => frame.ghosts)), label).toBeLessThanOrEqual(2) - await expect(question.locator("[data-ghost]")).toHaveCount(0) - expect(await options.evaluate((el) => el.scrollLeft), label).toBe(0) + return back.contains(document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)) } - }) - story("rolls the question count up going on and down going back", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const title = question.locator('[data-slot="question-header-title"]') - await expect(question.getByRole("radio").first()).toBeFocused() - await expect(title).toHaveText("1 of 5 questions") - - // The new digit comes up from below going on, and down from above going back. - const forward = await play(question, [{ key: "ArrowRight" }]) - await expect(title).toHaveText("2 of 5 questions") - const backward = await play(question, [{ key: "ArrowLeft" }]) - await expect(title).toHaveText("1 of 5 questions") - - expect(Math.max(...forward.map((frame) => frame.digit))).toBeGreaterThan(0.2) - expect(Math.min(...forward.map((frame) => frame.digit))).toBeGreaterThanOrEqual(0) - expect(Math.min(...backward.map((frame) => frame.digit))).toBeLessThan(-0.2) - expect(Math.max(...backward.map((frame) => frame.digit))).toBeLessThanOrEqual(0) - - for (const frames of [forward, backward]) expect(frames.at(-1)?.digit).toBe(0) - await expect(question.locator('[data-slot="rolling-number-glyph"][data-leaving]')).toHaveAttribute( - "data-leaving", - "", - ) - }) + // The faces are cut back from their start edge with `inset(top right bottom left)`. + const width = () => + Math.max( + ...faces.map((face) => { + if (opacity(face) < 0.01 || getComputedStyle(face).visibility === "hidden") return 0 + const inset = /inset\(([^)]*)\)/.exec(face.style.clipPath)?.[1]?.split(/\s+/).map(parseFloat) ?? [] - story("takes Next clicks and the submit shortcut while the footer is still moving", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const title = question.locator('[data-slot="question-header-title"]') - await expect(question.getByRole("radio").first()).toBeFocused() + return face.getBoundingClientRect().width - (inset[1] ?? 0) - (inset[3] ?? 0) + }), + ) - // Two Next clicks and two submit shortcuts, two frames apart, each landing while Back is still fading in. - const frames = await play(question, [ - { key: "next" }, - { key: "next", after: 2 }, - { key: "mod+Enter", after: 2 }, - { key: "mod+Enter", after: 2 }, - ]) + // `click:N` clicks the Nth progress dash, `next` clicks the Next or Submit button in use, and `mod+Enter` presses the + // submit shortcut. + const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform) + + const press = (key: string) => { + if (key === "next") + return root + .querySelector('[data-slot="question-advance-face"][data-active="true"] button') + ?.click() + + if (key === "mod+Enter") + return (document.activeElement ?? root).dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + metaKey: mac, + ctrlKey: !mac, + bubbles: true, + cancelable: true, + }), + ) - expect([2, 4, 6].every((index) => frames[index - 1]?.footer)).toBe(true) - await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeVisible() - await expect(title).toHaveText("5 of 5 questions") - }) + const dash = /^click:(\d+)$/.exec(key)?.[1] - story("rolls the count on from where it is when it changes again mid-roll", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const title = question.locator('[data-slot="question-header-title"]') - await expect(question.getByRole("radio").first()).toBeFocused() + if (dash) + return root.querySelectorAll('[data-slot="question-progress-segment"]')[Number(dash)]?.click() - // 1 → 2 → 3 and 1 → 2 → 1, the second change a few frames into the first roll. - for (const [second, end] of [ - ["ArrowRight", "3"], - ["ArrowLeft", "1"], - ] as const) - for (const after of [1, 2, 4]) { - const frames = await play(question, [{ key: "ArrowRight" }, { key: second, after }]) - const label = `1 → 2 → ${end} after ${after} frames` + return (document.activeElement ?? root).dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ) + } - const shown = (frame: (typeof frames)[number], text: string) => - frame.glyphs.filter((glyph) => glyph.text === text).reduce((sum, glyph) => sum + glyph.shown, 0) + const frames: { + text: number + answers: number + ghosts: number + x: number + scale: number + blur: boolean + mask: boolean + digit: number + bar: number + back: number + hit: boolean + next: number + submit: number + width: number + words: number + highlights: number + footer: boolean + animations: number + title: string + glyphs: { text: string; shown: number }[] + }[] = [] + + const queue = [...keys] + let wait = 0 + let still = 0 + + await new Promise((resolve) => { + const tick = () => { + while (queue.length > 0 && wait <= 0) { + const next = queue.shift()! + press(next.key) + wait = queue[0]?.after ?? 0 + } + + wait-- + const el = live() + const style = el ? getComputedStyle(el) : undefined + const matrix = new DOMMatrix(style?.transform === "none" ? undefined : style?.transform) + + const frame = { + text: shown("question-details"), + answers: shown("question-options-list"), + ghosts: root.querySelectorAll("[data-ghost]").length, + x: matrix.m41, + scale: matrix.m11, + blur: (style?.filter ?? "none") !== "none", + mask: mask(), + digit: roll(), + bar: bar(), + back: getComputedStyle(back).visibility === "hidden" ? 0 : opacity(back), + hit: hit(), + next: getComputedStyle(faces[0]!).visibility === "hidden" ? 0 : opacity(faces[0]!), + submit: getComputedStyle(faces[1]!).visibility === "hidden" ? 0 : opacity(faces[1]!), + width: width(), + words: Math.min( + ...faces.map((face) => + getComputedStyle(face).visibility === "hidden" + ? 0 + : opacity(face.querySelector('[data-slot="question-advance-label"]')!), + ), + ), + highlights: root.querySelectorAll('[data-slot="question-highlight"]').length, + footer: !!back.getAttribute("style") || faces.some((face) => !!face.getAttribute("style")), + animations: document.getAnimations().length, + title: root.querySelector('[data-slot="question-header-title"]')?.textContent ?? "", + glyphs: glyphs(), + } + + frames.push(frame) + + const resting = + queue.length === 0 && + frame.ghosts === 0 && + style?.transform === "none" && + !frame.blur && + !frame.mask && + frame.digit === 0 && + !frame.footer && + frame.bar === frames.at(-2)?.bar + + still = resting ? still + 1 : 0 + + if (still >= 3 || frames.length >= 240) return resolve() + requestAnimationFrame(tick) + } - // The digits drawn always add up to at least one whole digit, and the header's text never doubles a digit. - expect( - Math.min(...frames.map((frame) => frame.glyphs.reduce((sum, glyph) => sum + glyph.shown, 0))), - label, - ).toBeGreaterThanOrEqual(0.95) - expect( - frames.every((frame) => /^\d of 5 questions$/.test(frame.title.replace(/\s+/g, " ").trim())), - label, - ).toBe(true) + requestAnimationFrame(tick) + }) - // A 2 that had barely appeared goes back out and never crosses the middle. The frame sampled right after the - // second key shows the digits where the change found them; going back always sends the 2 out. - const at = shown(frames[after]!, "2") + return frames + }, keys) - if (second === "ArrowLeft" || at < 0.45) - expect(Math.max(...frames.slice(after).map((frame) => shown(frame, "2"))), label).toBeLessThanOrEqual( - at + 0.02, - ) - expect(frames.at(-1)?.glyphs, label).toEqual([{ text: end, shown: 1 }]) - await expect(title).toHaveText(`${end} of 5 questions`) - await play(question, [{ key: "click:0" }]) + // The dock's motion is off unless its knob is on, so these turn it on before the dock loads. + story.describe("with the question dock's motion on", () => { + story.beforeEach(({ page }) => + page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))), + ) + + story("keeps a question on screen while switching", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + + const forward = await play(question, [{ key: "ArrowRight" }]) + await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeVisible() + const backward = await play(question, [{ key: "ArrowLeft" }]) + await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible() + await expect(question.locator("[data-ghost]")).toHaveCount(0) + + for (const frames of [forward, backward]) { + expect(frames.length).toBeLessThan(240) + expect(Math.min(...frames.map((frame) => frame.text))).toBeGreaterThanOrEqual(0.9) + expect(Math.min(...frames.map((frame) => frame.answers))).toBeGreaterThanOrEqual(0.9) + expect(frames.at(-1)).toMatchObject({ x: 0, scale: 1, blur: false }) } - }) - // Where the bar sits against each dash, from the inline start of the progress row. - const dashes = (question: Locator) => - question.evaluate((root) => ({ - dashes: [...root.querySelectorAll('[data-slot="question-progress-bar"]')].map( - (el) => el.getBoundingClientRect().left, - ), - bar: root.querySelector('[data-slot="question-progress-current"]')!.getBoundingClientRect().left, - })) - - for (const dir of ["ltr", "rtl"] as const) - story(`slides the progress bar straight to a clicked dash (${dir})`, async ({ mount, page }) => { + expect(Math.min(...forward.map((frame) => frame.scale))).toBeLessThan(1) + expect(forward.some((frame) => frame.blur)).toBe(true) + }) + + story("switches questions without a second answer highlight", async ({ mount, page }) => { const component = await mount("session-question-switch--question-series") const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const highlight = question.locator('[data-slot="question-highlight"]') await expect(question.getByRole("radio").first()).toBeFocused() - await page.evaluate((dir) => document.documentElement.setAttribute("dir", dir), dir) - - const start = await dashes(question) - expect(start.bar).toBeCloseTo(start.dashes[0]!, 0) - - // Jumping from the first question to the fourth slides the one bar across the dashes in between, one way only. - const frames = await play(question, [{ key: "click:3" }]) - const end = await dashes(question) - const bars = frames.map((frame) => frame.bar) - const way = Math.sign(end.dashes[3]! - end.dashes[0]!) - expect(end.bar).toBeCloseTo(end.dashes[3]!, 0) - expect(bars.at(-1)).toBeCloseTo(end.dashes[3]!, 0) - expect(bars.some((x) => way * (x - end.dashes[0]!) > 1 && way * (x - end.dashes[3]!) < -1)).toBe(true) - expect(bars.slice(1).every((x, i) => way * (x - bars[i]!) >= -0.01)).toBe(true) - await expect(question.locator('[data-slot="question-header-title"]')).toHaveText("4 of 5 questions") - - // In RTL the dashes run from the right, so the bar moves left. - expect(way).toBe(dir === "rtl" ? -1 : 1) + await page.keyboard.press("ArrowDown") + await expect(highlight).toBeVisible() + + // The leaving copy of the answers carries no highlight, so only the new question's answer is ever highlighted. + const frames = await play(question, [{ key: "ArrowRight" }]) + expect(frames.some((frame) => frame.ghosts > 0)).toBe(true) + expect(frames.every((frame) => frame.highlights === 1)).toBe(true) }) - story("swaps questions, the count and the progress bar at once under reduced motion", async ({ mount, page }) => { - await page.emulateMedia({ reducedMotion: "reduce" }) - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question.getByRole("radio").first()).toBeFocused() + story("keeps one outgoing copy and a visible question through rapid switches", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const options = question.locator('[data-slot="question-options"]') + await expect(question.getByRole("radio").first()).toBeFocused() + // From the middle question, so every run can go two either way and comes back. + await play(question, [{ key: "ArrowRight" }, { key: "ArrowRight", after: 30 }]) + + const runs = [ + ["ArrowRight", "ArrowLeft"], + ["ArrowLeft", "ArrowRight"], + ["ArrowRight", "ArrowRight", "ArrowLeft", "ArrowLeft"], + ["ArrowLeft", "ArrowLeft", "ArrowRight", "ArrowRight"], + ] + + for (const run of runs) + for (const after of [0, 1, 2, 4]) { + const frames = await play( + question, + run.map((key) => ({ key, after })), + ) - const frames = await play(question, [{ key: "ArrowRight" }]) - expect(frames.every((frame) => frame.ghosts === 0 && frame.x === 0 && frame.scale === 1 && !frame.blur)).toBe(true) - expect(Math.min(...frames.map((frame) => frame.text))).toBe(1) - expect(frames.every((frame) => frame.digit === 0)).toBe(true) - expect(new Set(frames.map((frame) => Math.round(frame.bar))).size).toBe(1) - expect(frames.every((frame) => frame.back === 1 && !frame.footer)).toBe(true) - await expect(question.locator('[data-slot="question-header-title"]')).toHaveText("2 of 5 questions") + const label = `${run.join(" ")} every ${after} frames` + expect(frames.length, label).toBeLessThan(240) + expect(Math.min(...frames.map((frame) => frame.text)), label).toBeGreaterThanOrEqual(0.5) + expect(Math.min(...frames.map((frame) => frame.answers)), label).toBeGreaterThanOrEqual(0.5) + expect(Math.max(...frames.map((frame) => frame.ghosts)), label).toBeLessThanOrEqual(2) + await expect(question.locator("[data-ghost]")).toHaveCount(0) + expect(await options.evaluate((el) => el.scrollLeft), label).toBe(0) + } + }) - const last = await play(question, [{ key: "click:4" }]) - expect(last.every((frame) => frame.next === 0 && frame.submit === 1 && !frame.footer)).toBe(true) - }) + story("rolls the question count up going on and down going back", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const title = question.locator('[data-slot="question-header-title"]') + await expect(question.getByRole("radio").first()).toBeFocused() + await expect(title).toHaveText("1 of 5 questions") + + // The new digit comes up from below going on, and down from above going back. + const forward = await play(question, [{ key: "ArrowRight" }]) + await expect(title).toHaveText("2 of 5 questions") + const backward = await play(question, [{ key: "ArrowLeft" }]) + await expect(title).toHaveText("1 of 5 questions") + + expect(Math.max(...forward.map((frame) => frame.digit))).toBeGreaterThan(0.2) + expect(Math.min(...forward.map((frame) => frame.digit))).toBeGreaterThanOrEqual(0) + expect(Math.min(...backward.map((frame) => frame.digit))).toBeLessThan(-0.2) + expect(Math.max(...backward.map((frame) => frame.digit))).toBeLessThanOrEqual(0) + + for (const frames of [forward, backward]) expect(frames.at(-1)?.digit).toBe(0) + await expect(question.locator('[data-slot="rolling-number-glyph"][data-leaving]')).toHaveAttribute( + "data-leaving", + "", + ) + }) - // Rises, or falls, all the way from one end to the other through values in between, never turning back. - const glides = (values: number[], from: number, to: number) => { - const way = Math.sign(to - from) - expect(values.at(0)! * way).toBeLessThan(to * way) - expect(values.at(-1)).toBeCloseTo(to, 2) - expect( - values.some( - (value) => way * (value - from) > 0.02 * way * (to - from) && way * (to - value) > 0.02 * way * (to - from), - ), - ).toBe(true) - expect(values.slice(1).every((value, i) => way * (value - values[i]!) >= -0.01)).toBe(true) - } + story("takes Next clicks and the submit shortcut while the footer is still moving", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const title = question.locator('[data-slot="question-header-title"]') + await expect(question.getByRole("radio").first()).toBeFocused() - story("fades Back in and out and turns Next into Submit with the switch", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const back = question.locator('[data-slot="question-footer-back"]') - await expect(question.getByRole("radio").first()).toBeFocused() - await expect(question.getByRole("button", { name: "Back", exact: true })).toHaveCount(0) - - // Going on from the first question, Back comes in and can be clicked at once, while Next stays where it is. - const nextBox = await question.getByRole("button", { name: "Next", exact: true }).boundingBox() - const arrive = await play(question, [{ key: "ArrowRight" }]) - glides( - arrive.map((frame) => frame.back), - 0, - 1, - ) - expect(arrive.every((frame) => frame.hit && frame.next === 1 && frame.submit === 0)).toBe(true) - expect(await question.getByRole("button", { name: "Next", exact: true }).boundingBox()).toMatchObject({ - x: nextBox!.x, - width: nextBox!.width, + // Two Next clicks and two submit shortcuts, two frames apart, each landing while Back is still fading in. + const frames = await play(question, [ + { key: "next" }, + { key: "next", after: 2 }, + { key: "mod+Enter", after: 2 }, + { key: "mod+Enter", after: 2 }, + ]) + + expect([2, 4, 6].every((index) => frames[index - 1]?.footer)).toBe(true) + await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeVisible() + await expect(title).toHaveText("5 of 5 questions") }) - // On to the last question, Next fades into Submit while the button's edge sweeps to Submit's width. - await play(question, [{ key: "click:3" }]) + story("rolls the count on from where it is when it changes again mid-roll", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const title = question.locator('[data-slot="question-header-title"]') + await expect(question.getByRole("radio").first()).toBeFocused() - const widths = await question.evaluate((root) => - [...root.querySelectorAll('[data-slot="question-advance-face"]')].map( - (face) => face.getBoundingClientRect().width, - ), - ) + // 1 → 2 → 3 and 1 → 2 → 1, the second change a few frames into the first roll. + for (const [second, end] of [ + ["ArrowRight", "3"], + ["ArrowLeft", "1"], + ] as const) + for (const after of [1, 2, 4]) { + const frames = await play(question, [{ key: "ArrowRight" }, { key: second, after }]) + const label = `1 → 2 → ${end} after ${after} frames` + + const shown = (frame: (typeof frames)[number], text: string) => + frame.glyphs.filter((glyph) => glyph.text === text).reduce((sum, glyph) => sum + glyph.shown, 0) + + // The digits drawn always add up to at least one whole digit, and the header's text never doubles a digit. + expect( + Math.min(...frames.map((frame) => frame.glyphs.reduce((sum, glyph) => sum + glyph.shown, 0))), + label, + ).toBeGreaterThanOrEqual(0.95) + expect( + frames.every((frame) => /^\d of 5 questions$/.test(frame.title.replace(/\s+/g, " ").trim())), + label, + ).toBe(true) + + // A 2 that had barely appeared goes back out and never crosses the middle. The frame sampled right after the + // second key shows the digits where the change found them; going back always sends the 2 out. + const at = shown(frames[after]!, "2") + + if (second === "ArrowLeft" || at < 0.45) + expect(Math.max(...frames.slice(after).map((frame) => shown(frame, "2"))), label).toBeLessThanOrEqual( + at + 0.02, + ) + expect(frames.at(-1)?.glyphs, label).toEqual([{ text: end, shown: 1 }]) + await expect(title).toHaveText(`${end} of 5 questions`) + await play(question, [{ key: "click:0" }]) + } + }) - const submit = await play(question, [{ key: "ArrowRight" }]) - glides( - submit.map((frame) => frame.next), - 1, - 0, - ) - glides( - submit.map((frame) => frame.submit), - 0, - 1, - ) - glides( - submit.map((frame) => frame.width), - widths[0]!, - widths[1]!, - ) - expect(submit.some((frame) => frame.next > 0.1 && frame.submit > 0.1)).toBe(true) - // The buttons overlap, but the words "Next" and "Submit" are never both legible at once. - expect(Math.max(...submit.map((frame) => frame.words))).toBeLessThan(0.25) - expect(submit.every((frame) => frame.back === 1)).toBe(true) - - // Back to the first question, Back goes, and cannot be clicked from the moment it starts to. - const leave = await play(question, [{ key: "click:0" }]) - glides( - leave.map((frame) => frame.back), - 1, - 0, - ) - expect(leave.some((frame) => frame.hit)).toBe(false) - expect(leave.at(-1)).toMatchObject({ next: 1, submit: 0, footer: false }) - await expect(back).toHaveAttribute("inert", "") - }) + // Where the bar sits against each dash, from the inline start of the progress row. + const dashes = (question: Locator) => + question.evaluate((root) => ({ + dashes: [...root.querySelectorAll('[data-slot="question-progress-bar"]')].map( + (el) => el.getBoundingClientRect().left, + ), + bar: root.querySelector('[data-slot="question-progress-current"]')!.getBoundingClientRect().left, + })) + + for (const dir of ["ltr", "rtl"] as const) + story(`slides the progress bar straight to a clicked dash (${dir})`, async ({ mount, page }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio").first()).toBeFocused() + await page.evaluate((dir) => document.documentElement.setAttribute("dir", dir), dir) + + const start = await dashes(question) + expect(start.bar).toBeCloseTo(start.dashes[0]!, 0) + + // Jumping from the first question to the fourth slides the one bar across the dashes in between, one way only. + const frames = await play(question, [{ key: "click:3" }]) + const end = await dashes(question) + const bars = frames.map((frame) => frame.bar) + const way = Math.sign(end.dashes[3]! - end.dashes[0]!) + expect(end.bar).toBeCloseTo(end.dashes[3]!, 0) + expect(bars.at(-1)).toBeCloseTo(end.dashes[3]!, 0) + expect(bars.some((x) => way * (x - end.dashes[0]!) > 1 && way * (x - end.dashes[3]!) < -1)).toBe(true) + expect(bars.slice(1).every((x, i) => way * (x - bars[i]!) >= -0.01)).toBe(true) + await expect(question.locator('[data-slot="question-header-title"]')).toHaveText("4 of 5 questions") + + // In RTL the dashes run from the right, so the bar moves left. + expect(way).toBe(dir === "rtl" ? -1 : 1) + }) - story("stops a switch where it is when the knob turns off", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question.getByRole("radio").first()).toBeFocused() + story("swaps questions, the count and the progress bar at once under reduced motion", async ({ mount, page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }) + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio").first()).toBeFocused() - const frames = await question.evaluate( - async (root, tuning) => { - const { motionTuning } = await import(tuning) - const knob = motionTuning.list().find((item: MotionKnob) => item.id === "question.motion") - const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)) + const frames = await play(question, [{ key: "ArrowRight" }]) + expect(frames.every((frame) => frame.ghosts === 0 && frame.x === 0 && frame.scale === 1 && !frame.blur)).toBe( + true, + ) + expect(Math.min(...frames.map((frame) => frame.text))).toBe(1) + expect(frames.every((frame) => frame.digit === 0)).toBe(true) + expect(new Set(frames.map((frame) => Math.round(frame.bar))).size).toBe(1) + expect(frames.every((frame) => frame.back === 1 && !frame.footer)).toBe(true) + await expect(question.locator('[data-slot="question-header-title"]')).toHaveText("2 of 5 questions") + + const last = await play(question, [{ key: "click:4" }]) + expect(last.every((frame) => frame.next === 0 && frame.submit === 1 && !frame.footer)).toBe(true) + }) - const sample = () => ({ - animations: document.getAnimations().length, - heights: [...root.querySelectorAll("*")].filter((el) => el.style.height).length, - ghosts: root.querySelectorAll("[data-ghost]").length, - }) + // Rises, or falls, all the way from one end to the other through values in between, never turning back. + const glides = (values: number[], from: number, to: number) => { + const way = Math.sign(to - from) + expect(values.at(0)! * way).toBeLessThan(to * way) + expect(values.at(-1)).toBeCloseTo(to, 2) + expect( + values.some( + (value) => way * (value - from) > 0.02 * way * (to - from) && way * (to - value) > 0.02 * way * (to - from), + ), + ).toBe(true) + expect(values.slice(1).every((value, i) => way * (value - values[i]!) >= -0.01)).toBe(true) + } - document.activeElement!.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }), - ) - await frame() - await frame() - const moving = sample() - motionTuning.set(knob, false) - await frame() - await frame() - - return { moving, after: sample() } - }, - sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)), - ) + story("fades Back in and out and turns Next into Submit with the switch", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const back = question.locator('[data-slot="question-footer-back"]') + await expect(question.getByRole("radio").first()).toBeFocused() + await expect(question.getByRole("button", { name: "Back", exact: true })).toHaveCount(0) + + // Going on from the first question, Back comes in and can be clicked at once, while Next stays where it is. + const nextBox = await question.getByRole("button", { name: "Next", exact: true }).boundingBox() + const arrive = await play(question, [{ key: "ArrowRight" }]) + glides( + arrive.map((frame) => frame.back), + 0, + 1, + ) + expect(arrive.every((frame) => frame.hit && frame.next === 1 && frame.submit === 0)).toBe(true) + expect(await question.getByRole("button", { name: "Next", exact: true }).boundingBox()).toMatchObject({ + x: nextBox!.x, + width: nextBox!.width, + }) - expect(frames.moving.heights + frames.moving.ghosts).toBeGreaterThan(0) - expect(frames.after).toEqual({ animations: 0, heights: 0, ghosts: 0 }) - }) -}) + // On to the last question, Next fades into Submit while the button's edge sweeps to Submit's width. + await play(question, [{ key: "click:3" }]) -story("switches questions, folds and submits on the same frame with the dock's motion off", async ({ mount, page }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const title = question.locator('[data-slot="question-header-title"]') - await expect(question.getByRole("radio").first()).toBeFocused() - await expect(question.locator('[data-component="rolling-number"]')).toHaveCount(0) - await expect(question.locator('[data-slot="question-progress-track"]')).toHaveCount(0) - - // From the frame of the key on, the next question is at rest: no outgoing copy, nothing moved, faded or blurred, - // and no animation or transition running anywhere on the page. - for (const [key, count] of [ - ["ArrowRight", "2 of 5 questions"], - ["click:3", "4 of 5 questions"], - ["ArrowRight", "5 of 5 questions"], - ] as const) { - const frames = await play(question, [{ key }]) - expect(frames.length, key).toBeLessThanOrEqual(4) - expect( - frames.every( - (frame) => - frame.ghosts === 0 && - frame.x === 0 && - frame.scale === 1 && - !frame.blur && - !frame.mask && - frame.text === 1 && - frame.answers === 1 && - frame.back === 1 && - !frame.footer && - frame.animations === 0, - ), - key, - ).toBe(true) - await expect(title).toHaveText(count) - } + const widths = await question.evaluate((root) => + [...root.querySelectorAll('[data-slot="question-advance-face"]')].map( + (face) => face.getBoundingClientRect().width, + ), + ) - // Minimizing and restoring fold the answers away and back at once. - const fold = (label: string) => - question.evaluate(async (root, label) => { - const options = root.querySelector('[data-slot="question-options"]')! - const list = root.querySelector('[data-slot="question-options-list"]')! + const submit = await play(question, [{ key: "ArrowRight" }]) + glides( + submit.map((frame) => frame.next), + 1, + 0, + ) + glides( + submit.map((frame) => frame.submit), + 0, + 1, + ) + glides( + submit.map((frame) => frame.width), + widths[0]!, + widths[1]!, + ) + expect(submit.some((frame) => frame.next > 0.1 && frame.submit > 0.1)).toBe(true) + // The buttons overlap, but the words "Next" and "Submit" are never both legible at once. + expect(Math.max(...submit.map((frame) => frame.words))).toBeLessThan(0.25) + expect(submit.every((frame) => frame.back === 1)).toBe(true) + + // Back to the first question, Back goes, and cannot be clicked from the moment it starts to. + const leave = await play(question, [{ key: "click:0" }]) + glides( + leave.map((frame) => frame.back), + 1, + 0, + ) + expect(leave.some((frame) => frame.hit)).toBe(false) + expect(leave.at(-1)).toMatchObject({ next: 1, submit: 0, footer: false }) + await expect(back).toHaveAttribute("inert", "") + }) - const sample = () => ({ - height: Math.round(options.getBoundingClientRect().height), - list: Math.round(list.getBoundingClientRect().height), - hidden: getComputedStyle(options).visibility === "hidden", - animations: document.getAnimations().length, - }) + story("stops a switch where it is when the knob turns off", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio").first()).toBeFocused() - root.querySelector(`button[aria-label="${label}"]`)!.click() - const frames = [sample()] + const frames = await question.evaluate( + async (root, tuning) => { + const { motionTuning } = await import(tuning) + const knob = motionTuning.list().find((item: MotionKnob) => item.id === "question.motion") + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)) - for (let i = 0; i < 3; i++) { - await new Promise((resolve) => requestAnimationFrame(resolve)) - frames.push(sample()) - } + const sample = () => ({ + animations: document.getAnimations().length, + heights: [...root.querySelectorAll("*")].filter((el) => el.style.height).length, + ghosts: root.querySelectorAll("[data-ghost]").length, + }) - return frames - }, label) - - // The last question's answers may be cut to the dock's height, so they come back to the height they rest at. - const resting = await question - .locator('[data-slot="question-options"]') - .evaluate((el) => Math.round(el.getBoundingClientRect().height)) - - const folded = await fold("Minimize question") - expect(folded.every((frame) => frame.height === 0 && frame.hidden && frame.animations === 0)).toBe(true) - const restored = await fold("Restore question") - expect( - restored.every((frame) => frame.height > 0 && frame.height === resting && !frame.hidden && frame.animations === 0), - ).toBe(true) - - // Submitting swaps the dock for the composer on one frame, with no closing flight. - const submitted = await page.evaluate(async () => { - const dock = () => document.querySelector('[data-component="dock-prompt"][data-kind="question"]') - const moving = () => !!document.querySelector("[data-moving]") - document.querySelector('[data-slot="question-advance-face"][data-active="true"] button')!.click() - - for (let i = 0; i < 120 && dock() && !moving(); i++) await new Promise((resolve) => requestAnimationFrame(resolve)) - const frames = [] - - for (let i = 0; i < 3; i++) { - frames.push({ dock: !!dock(), moving: moving(), animations: document.getAnimations().length }) - await new Promise((resolve) => requestAnimationFrame(resolve)) - } + document.activeElement!.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }), + ) + await frame() + await frame() + const moving = sample() + motionTuning.set(knob, false) + await frame() + await frame() + + return { moving, after: sample() } + }, + sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)), + ) - return frames + expect(frames.moving.heights + frames.moving.ghosts).toBeGreaterThan(0) + expect(frames.after).toEqual({ animations: 0, heights: 0, ghosts: 0 }) + }) }) - expect(submitted).toEqual(Array(3).fill({ dock: false, moving: false, animations: 0 })) -}) + story( + "switches questions, folds and submits on the same frame with the dock's motion off", + async ({ mount, page }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const title = question.locator('[data-slot="question-header-title"]') + await expect(question.getByRole("radio").first()).toBeFocused() + await expect(question.locator('[data-component="rolling-number"]')).toHaveCount(0) + await expect(question.locator('[data-slot="question-progress-track"]')).toHaveCount(0) + + // From the frame of the key on, the next question is at rest: no outgoing copy, nothing moved, faded or blurred, + // and no animation or transition running anywhere on the page. + for (const [key, count] of [ + ["ArrowRight", "2 of 5 questions"], + ["click:3", "4 of 5 questions"], + ["ArrowRight", "5 of 5 questions"], + ] as const) { + const frames = await play(question, [{ key }]) + expect(frames.length, key).toBeLessThanOrEqual(4) + expect( + frames.every( + (frame) => + frame.ghosts === 0 && + frame.x === 0 && + frame.scale === 1 && + !frame.blur && + !frame.mask && + frame.text === 1 && + frame.answers === 1 && + frame.back === 1 && + !frame.footer && + frame.animations === 0, + ), + key, + ).toBe(true) + await expect(title).toHaveText(count) + } -story("toggles checkboxes with Space on key down and moves focus with arrows", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - await question.getByRole("button", { name: "Next", exact: true }).click() - const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) - const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) - await expect(files).toBeFocused() - - await page.keyboard.down(" ") - await expect(files).toBeChecked() - await page.keyboard.down(" ") - await page.keyboard.up(" ") - await expect(files).toBeChecked() - - await page.keyboard.press("ArrowDown") - await expect(terminal).toBeFocused() - await expect(terminal).not.toBeChecked() - await page.keyboard.press(" ") - await expect(terminal).toBeChecked() - await page.keyboard.press(" ") - await expect(terminal).not.toBeChecked() - await expect(files).toBeChecked() -}) + // Minimizing and restoring fold the answers away and back at once. + const fold = (label: string) => + question.evaluate(async (root, label) => { + const options = root.querySelector('[data-slot="question-options"]')! + const list = root.querySelector('[data-slot="question-options-list"]')! + + const sample = () => ({ + height: Math.round(options.getBoundingClientRect().height), + list: Math.round(list.getBoundingClientRect().height), + hidden: getComputedStyle(options).visibility === "hidden", + animations: document.getAnimations().length, + }) + + root.querySelector(`button[aria-label="${label}"]`)!.click() + const frames = [sample()] + + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => requestAnimationFrame(resolve)) + frames.push(sample()) + } + + return frames + }, label) + + // The last question's answers may be cut to the dock's height, so they come back to the height they rest at. + const resting = await question + .locator('[data-slot="question-options"]') + .evaluate((el) => Math.round(el.getBoundingClientRect().height)) + + const folded = await fold("Minimize question") + expect(folded.every((frame) => frame.height === 0 && frame.hidden && frame.animations === 0)).toBe(true) + const restored = await fold("Restore question") + expect( + restored.every( + (frame) => frame.height > 0 && frame.height === resting && !frame.hidden && frame.animations === 0, + ), + ).toBe(true) -story("ignores modified, out-of-range, and repeated number shortcuts", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const wide = question.getByRole("radio", { name: /Wide/ }) - await expect(focused).toBeFocused() - await focused.press("1") - await focused.press("9") - await focused.press("Alt+2") - await expect(focused).toBeChecked() - await expect(wide).not.toBeChecked() + // Submitting swaps the dock for the composer on one frame, with no closing flight. + const submitted = await page.evaluate(async () => { + const dock = () => document.querySelector('[data-component="dock-prompt"][data-kind="question"]') + const moving = () => !!document.querySelector("[data-moving]") + document.querySelector('[data-slot="question-advance-face"][data-active="true"] button')!.click() - await question.getByRole("button", { name: "Next", exact: true }).click() - const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) - await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused() - await page.keyboard.down("2") - await expect(terminal).toBeChecked() - await page.keyboard.down("2") - await expect(terminal).toBeChecked() - await page.keyboard.up("2") -}) + for (let i = 0; i < 120 && dock() && !moving(); i++) + await new Promise((resolve) => requestAnimationFrame(resolve)) + const frames = [] -story("types digits into the custom answer without selecting numbered options", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true }) - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - await expect(focused).toBeFocused() - await focused.press("3") - await expect(input).toBeFocused() - await input.press("1") - await input.press("2") - await input.press("3") - await expect(input).toHaveValue("123") - await expect(custom).toBeChecked() - await expect(focused).not.toBeChecked() - await expect(question.getByRole("radio", { name: /Wide/ })).not.toBeChecked() -}) + for (let i = 0; i < 3; i++) { + frames.push({ dock: !!dock(), moving: moving(), animations: document.getAnimations().length }) + await new Promise((resolve) => requestAnimationFrame(resolve)) + } -story("leaves Enter and Escape to an IME composition in the custom answer", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true }) - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - await input.focus() - await input.fill("にほん") - - const compose = (key: string, keyCode: number) => - input.evaluate( - (element, init) => - element.dispatchEvent(new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true })), - { key, keyCode, isComposing: keyCode !== 229 }, - ) + return frames + }) - await compose("Enter", 13) - await compose("Escape", 27) - await compose("Enter", 229) - await compose("Escape", 229) - await expect(input).toBeFocused() - await expect(input).toHaveValue("にほん") - await expect(question).toBeVisible() - await input.press("Escape") - await expect(toggle).toBeFocused() - await expect(question).toBeVisible() -}) + expect(submitted).toEqual(Array(3).fill({ dock: false, moving: false, animations: 0 })) + }, + ) -story("shows question navigation shortcuts in buttons and tooltips", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-request") - const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform)) - const shortcut = mac ? "⌘⏎" : "Ctrl+⏎" - const next = component.getByRole("button", { name: "Next", exact: true }) - await expect(next.locator('[data-slot="question-submit-shortcut"]')).toBeVisible() - await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut) - await expect(next).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter") - await next.hover() - await expect(page.getByRole("tooltip")).toContainText("Next") - await expect(page.getByRole("tooltip")).toContainText(shortcut) - await next.press(mac ? "Meta+Enter" : "Control+Enter") - const submit = component.getByRole("button", { name: "Submit", exact: true }) - await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toBeVisible() - await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut) - await expect(submit).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter") - // Pressing the button closes its tooltip until the pointer comes back to it. - await page.mouse.move(0, 0) - await submit.hover() - await expect(page.getByRole("tooltip")).toContainText("Submit") - await expect(page.getByRole("tooltip")).toContainText(shortcut) - const back = component.getByRole("button", { name: "Back", exact: true }) - await expect(back).toHaveText("Back") - await expect(back).toHaveAttribute("aria-keyshortcuts", mac ? "Meta+[" : "Alt+ArrowLeft") - await back.hover() - // Moving straight from Submit opens Back's tooltip while Submit's is still fading out. - await expect(page.getByRole("tooltip", { name: /Back/ })).toContainText(mac ? "⌘[" : "Alt+←") - await back.press(mac ? "Meta+[" : "Alt+ArrowLeft") - await expect(next).toBeVisible() -}) + story("toggles checkboxes with Space on key down and moves focus with arrows", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await question.getByRole("button", { name: "Next", exact: true }).click() + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) + await expect(files).toBeFocused() -story("keeps the custom answer input and dock geometry stable while editing", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const row = question.locator('[data-slot="question-option"][data-custom="true"]') - const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) - const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true }) - await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() - await expect(input).toBeEditable() - await expect(input).toHaveValue("") - const node = await input.evaluateHandle((element) => element) - const height = () => question.evaluate((element) => element.getBoundingClientRect().height) - const initial = await height() - const draft = "A custom answer long enough to wrap if the editor becomes multiline instead of staying single-line" - - const predefined = question.getByRole("radio", { name: /Focused/ }) - await predefined.click() - await input.focus() - await expect(input).toBeFocused() - await expect(predefined).toBeChecked() - await expect(toggle).not.toBeChecked() - await row.click({ position: { x: 4, y: 4 } }) - await expect(toggle).toBeChecked() - await expect(input).toBeFocused() - await expect(input).toHaveValue("") - await expect(predefined).not.toBeChecked() - await input.fill(draft) - await expect(input).toHaveValue(draft) - await expect(toggle).toBeChecked() - await expect(predefined).not.toBeChecked() - await expect.poll(height).toBe(initial) - await input.press("Escape") - await expect(toggle).toBeFocused() - await expect(input).toHaveValue(draft) - await row.click({ position: { x: 30, y: 20 } }) - await expect(input).toBeFocused() - await expect(input).toHaveValue(draft) - await expect - .poll(() => input.evaluate((element, original) => element === original && original.isConnected, node)) - .toBe(true) - await node.dispose() -}) + await page.keyboard.down(" ") + await expect(files).toBeChecked() + await page.keyboard.down(" ") + await page.keyboard.up(" ") + await expect(files).toBeChecked() -story("marks one-answer questions with round keys and many-answer questions with square keys", async ({ mount }) => { - const component = await mount("app-current-session-surface--question-request") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const keys = question.locator('[data-slot="question-option-key"]') - const focused = question.getByRole("radio", { name: /Focused/ }) - await expect(focused).toBeFocused() - await expect(keys).toHaveText(["1", "2", "3"]) - await expect(keys.first()).toBeVisible() - await expect(keys.first()).toHaveAttribute("data-type", "radio") - await expect(question.getByText("Pick any", { exact: true })).toHaveCount(0) - await focused.press("1") - await expect(focused.locator('[data-slot="question-option-key"]')).toHaveAttribute("data-picked", "true") + await page.keyboard.press("ArrowDown") + await expect(terminal).toBeFocused() + await expect(terminal).not.toBeChecked() + await page.keyboard.press(" ") + await expect(terminal).toBeChecked() + await page.keyboard.press(" ") + await expect(terminal).not.toBeChecked() + await expect(files).toBeChecked() + }) - await question.getByRole("button", { name: "Next", exact: true }).click() - const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) - await expect(files).toBeFocused() - await expect(keys.first()).toHaveAttribute("data-type", "checkbox") - await expect(question.getByText("Pick any", { exact: true })).toBeVisible() -}) + story("ignores modified, out-of-range, and repeated number shortcuts", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const wide = question.getByRole("radio", { name: /Wide/ }) + await expect(focused).toBeFocused() + await focused.press("1") + await focused.press("9") + await focused.press("Alt+2") + await expect(focused).toBeChecked() + await expect(wide).not.toBeChecked() + + await question.getByRole("button", { name: "Next", exact: true }).click() + const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) + await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused() + await page.keyboard.down("2") + await expect(terminal).toBeChecked() + await page.keyboard.down("2") + await expect(terminal).toBeChecked() + await page.keyboard.up("2") + }) -story("toggles a lone many-answer question and sends it with Enter", async ({ mount, page }) => { - const component = await mount("app-current-session-surface--question-multi-select") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) - const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) - await expect(files).toBeFocused() - await expect(question.getByText("Pick any", { exact: true })).toBeVisible() - await expect(question.locator('[data-slot="question-option-key"][data-type="radio"]')).toHaveCount(0) - - await page.keyboard.press(" ") - await page.keyboard.press("2") - await expect(files).toBeChecked() - await expect(terminal).toBeChecked() - await expect(terminal.locator('[data-slot="question-option-key"]')).toHaveAttribute("data-picked", "true") - await page.keyboard.press("Enter") - await expect(question).toBeHidden() -}) + story("types digits into the custom answer without selecting numbered options", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + await expect(focused).toBeFocused() + await focused.press("3") + await expect(input).toBeFocused() + await input.press("1") + await input.press("2") + await input.press("3") + await expect(input).toHaveValue("123") + await expect(custom).toBeChecked() + await expect(focused).not.toBeChecked() + await expect(question.getByRole("radio", { name: /Wide/ })).not.toBeChecked() + }) -const longList = async (mount: (id: string) => Promise) => { - const component = await mount("session-question-switch--long-list") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question.getByRole("checkbox").first()).toBeFocused() - // The answers box has opened to its first height once it stops changing. - const box = () => question.evaluate((root) => Math.round(root.getBoundingClientRect().height)) - await expect.poll(async () => (await box()) === (await box())).toBe(true) - - return question -} - -const fit = (question: Locator) => - question.evaluate((root) => { - const options = root.querySelector('[data-slot="question-options"]')! - - const timeline = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') - const box = root.getBoundingClientRect() - - return { - height: box.height, - // The whole dock, footer included, sits between the top of the timeline and the foot of the window. - within: box.top >= (timeline?.getBoundingClientRect().top ?? 0) - 0.5 && box.bottom <= innerHeight + 0.5, - scrolls: options.scrollHeight > options.clientHeight + 1, - top: options.scrollTop, - } + story("leaves Enter and Escape to an IME composition in the custom answer", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true }) + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + await input.focus() + await input.fill("にほん") + + const compose = (key: string, keyCode: number) => + input.evaluate( + (element, init) => + element.dispatchEvent(new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true })), + { key, keyCode, isComposing: keyCode !== 229 }, + ) + + await compose("Enter", 13) + await compose("Escape", 27) + await compose("Enter", 229) + await compose("Escape", 229) + await expect(input).toBeFocused() + await expect(input).toHaveValue("にほん") + await expect(question).toBeVisible() + await input.press("Escape") + await expect(toggle).toBeFocused() + await expect(question).toBeVisible() }) -// Whether the whole of an answer's row shows, clear of the feathered edges. -const clear = (row: Locator) => - row.evaluate((input) => { - const row = input.closest('[data-slot="question-option"]')! - const box = input.closest('[data-slot="question-options"]')!.getBoundingClientRect() - const at = row.getBoundingClientRect() + story("shows question navigation shortcuts in buttons and tooltips", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform)) + const shortcut = mac ? "⌘⏎" : "Ctrl+⏎" + const next = component.getByRole("button", { name: "Next", exact: true }) + await expect(next.locator('[data-slot="question-submit-shortcut"]')).toBeVisible() + await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut) + await expect(next).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter") + await next.hover() + await expect(page.getByRole("tooltip")).toContainText("Next") + await expect(page.getByRole("tooltip")).toContainText(shortcut) + await next.press(mac ? "Meta+Enter" : "Control+Enter") + const submit = component.getByRole("button", { name: "Submit", exact: true }) + await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toBeVisible() + await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut) + await expect(submit).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter") + // Pressing the button closes its tooltip until the pointer comes back to it. + await page.mouse.move(0, 0) + await submit.hover() + await expect(page.getByRole("tooltip")).toContainText("Submit") + await expect(page.getByRole("tooltip")).toContainText(shortcut) + const back = component.getByRole("button", { name: "Back", exact: true }) + await expect(back).toHaveText("Back") + await expect(back).toHaveAttribute("aria-keyshortcuts", mac ? "Meta+[" : "Alt+ArrowLeft") + await back.hover() + // Moving straight from Submit opens Back's tooltip while Submit's is still fading out. + await expect(page.getByRole("tooltip", { name: /Back/ })).toContainText(mac ? "⌘[" : "Alt+←") + await back.press(mac ? "Meta+[" : "Alt+ArrowLeft") + await expect(next).toBeVisible() + }) - return at.top >= box.top + 20 - 0.5 && at.bottom <= box.bottom - 20 + 0.5 + story("keeps the custom answer input and dock geometry stable while editing", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const row = question.locator('[data-slot="question-option"][data-custom="true"]') + const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true }) + const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true }) + await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused() + await expect(input).toBeEditable() + await expect(input).toHaveValue("") + const node = await input.evaluateHandle((element) => element) + const height = () => question.evaluate((element) => element.getBoundingClientRect().height) + const initial = await height() + const draft = "A custom answer long enough to wrap if the editor becomes multiline instead of staying single-line" + + const predefined = question.getByRole("radio", { name: /Focused/ }) + await predefined.click() + await input.focus() + await expect(input).toBeFocused() + await expect(predefined).toBeChecked() + await expect(toggle).not.toBeChecked() + await row.click({ position: { x: 4, y: 4 } }) + await expect(toggle).toBeChecked() + await expect(input).toBeFocused() + await expect(input).toHaveValue("") + await expect(predefined).not.toBeChecked() + await input.fill(draft) + await expect(input).toHaveValue(draft) + await expect(toggle).toBeChecked() + await expect(predefined).not.toBeChecked() + await expect.poll(height).toBe(initial) + await input.press("Escape") + await expect(toggle).toBeFocused() + await expect(input).toHaveValue(draft) + await row.click({ position: { x: 30, y: 20 } }) + await expect(input).toBeFocused() + await expect(input).toHaveValue(draft) + await expect + .poll(() => input.evaluate((element, original) => element === original && original.isConnected, node)) + .toBe(true) + await node.dispose() }) -const feathered = (question: Locator) => - question.locator('[data-slot="question-options"]').evaluate((options) => { - const mask = options.style.maskImage + story("marks one-answer questions with round keys and many-answer questions with square keys", async ({ mount }) => { + const component = await mount("app-current-session-surface--question-request") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const keys = question.locator('[data-slot="question-option-key"]') + const focused = question.getByRole("radio", { name: /Focused/ }) + await expect(focused).toBeFocused() + await expect(keys).toHaveText(["1", "2", "3"]) + await expect(keys.first()).toBeVisible() + await expect(keys.first()).toHaveAttribute("data-type", "radio") + await expect(question.getByText("Pick any", { exact: true })).toHaveCount(0) + await focused.press("1") + await expect(focused.locator('[data-slot="question-option-key"]')).toHaveAttribute("data-picked", "true") + + await question.getByRole("button", { name: "Next", exact: true }).click() + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + await expect(files).toBeFocused() + await expect(keys.first()).toHaveAttribute("data-type", "checkbox") + await expect(question.getByText("Pick any", { exact: true })).toBeVisible() + }) - // A gradient down the list may be written without its direction, which is the default. - return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") } + story("toggles a lone many-answer question and sends it with Enter", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-multi-select") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const files = question.getByRole("checkbox", { name: "Changed files", exact: true }) + const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true }) + await expect(files).toBeFocused() + await expect(question.getByText("Pick any", { exact: true })).toBeVisible() + await expect(question.locator('[data-slot="question-option-key"][data-type="radio"]')).toHaveCount(0) + + await page.keyboard.press(" ") + await page.keyboard.press("2") + await expect(files).toBeChecked() + await expect(terminal).toBeChecked() + await expect(terminal.locator('[data-slot="question-option-key"]')).toHaveAttribute("data-picked", "true") + await page.keyboard.press("Enter") + await expect(question).toBeHidden() }) -story("caps a long list of answers to the panel and scrolls them inside it", async ({ mount }) => { - const question = await longList(mount) - const rest = await fit(question) - expect(rest.within).toBe(true) - expect(rest.scrolls).toBe(true) - await expect(question.getByRole("checkbox", { name: /Footer/ })).not.toBeInViewport() -}) + const longList = async (mount: (id: string) => Promise) => { + const component = await mount("session-question-switch--long-list") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("checkbox").first()).toBeFocused() + // The answers box has opened to its first height once it stops changing. + const box = () => question.evaluate((root) => Math.round(root.getBoundingClientRect().height)) + await expect.poll(async () => (await box()) === (await box())).toBe(true) -story("scrolls the highlighted answer into view with the keys", async ({ mount, page }) => { - const question = await longList(mount) - const footer = question.getByRole("checkbox", { name: /Footer/ }) + return question + } - await page.keyboard.press("End") - await page.keyboard.press("ArrowUp") - await expect(footer).toBeFocused() - expect(await clear(footer)).toBe(true) + const fit = (question: Locator) => + question.evaluate((root) => { + const options = root.querySelector('[data-slot="question-options"]')! - await page.keyboard.press("Home") - await expect(question.getByRole("checkbox").first()).toBeFocused() - expect((await fit(question)).top).toBe(0) -}) + const timeline = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') + const box = root.getBoundingClientRect() -story("feathers only the edges of the answers with more past them", async ({ mount }) => { - const question = await longList(mount) + return { + height: box.height, + // The whole dock, footer included, sits between the top of the timeline and the foot of the window. + within: box.top >= (timeline?.getBoundingClientRect().top ?? 0) - 0.5 && box.bottom <= innerHeight + 0.5, + scrolls: options.scrollHeight > options.clientHeight + 1, + top: options.scrollTop, + } + }) - // Scrolls to a share of the way down the list. - const edges = (share: number) => - question.evaluate(async (root, share) => { - const options = root.querySelector('[data-slot="question-options"]')! - options.scrollTop = Math.round((options.scrollHeight - options.clientHeight) * share) - await new Promise((done) => options.addEventListener("scroll", done, { once: true })) + // Whether the whole of an answer's row shows, clear of the feathered edges. + const clear = (row: Locator) => + row.evaluate((input) => { + const row = input.closest('[data-slot="question-option"]')! + const box = input.closest('[data-slot="question-options"]')!.getBoundingClientRect() + const at = row.getBoundingClientRect() + + return at.top >= box.top + 20 - 0.5 && at.bottom <= box.bottom - 20 + 0.5 + }) + + const feathered = (question: Locator) => + question.locator('[data-slot="question-options"]').evaluate((options) => { const mask = options.style.maskImage // A gradient down the list may be written without its direction, which is the default. return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") } - }, share) - - expect(await edges(0.5)).toEqual({ top: true, bottom: true }) - expect(await edges(1)).toEqual({ top: true, bottom: false }) - expect(await edges(0)).toEqual({ top: false, bottom: true }) -}) - -story("leaves a list that fits without feathered edges", async ({ mount }) => { - const component = await mount("session-question-switch--question-series") - const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question.getByRole("radio").first()).toBeFocused() - await expect - .poll(() => question.locator('[data-slot="question-options"]').evaluate((el) => el.style.maskImage)) - .toBe("") -}) - -story( - "caps a long list to the panel in the app's layout, where the dock overflows a zero-height row", - async ({ mount }) => { - const question = await longList(mount) - // As in the session screen, the composer region moves into a zero-height row at the foot of the panel and overflows - // it upward, over the timeline. - await question.evaluate((root) => { - const dock = root.closest('[data-component="session-composer-dock"]')! - const row = document.createElement("div") - row.style.cssText = - "position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 0" - row.style.flexShrink = "0" - dock.replaceWith(row) - row.append(dock) - dispatchEvent(new Event("resize")) }) - // Measured against the empty row, the cap would fall to its 240px minimum. - await expect.poll(async () => (await fit(question)).height).toBeGreaterThan(400) + story("caps a long list of answers to the panel and scrolls them inside it", async ({ mount }) => { + const question = await longList(mount) const rest = await fit(question) expect(rest.within).toBe(true) expect(rest.scrolls).toBe(true) - }, -) + await expect(question.getByRole("checkbox", { name: /Footer/ })).not.toBeInViewport() + }) -story("brings the picked answer back into view after switching questions", async ({ mount, page }) => { - const question = await longList(mount) - const footer = question.getByRole("checkbox", { name: /Footer/ }) - const title = question.locator('[data-slot="question-header-title"]') - - await page.keyboard.press("End") - await page.keyboard.press("ArrowUp") - await page.keyboard.press(" ") - await expect(footer).toBeChecked() - - await page.keyboard.press("ArrowRight") - await expect(title).toHaveText("2 of 2 questions") - await page.keyboard.press("ArrowLeft") - await expect(title).toHaveText("1 of 2 questions") - await expect(footer).toBeFocused() - expect(await clear(footer)).toBe(true) -}) + story("scrolls the highlighted answer into view with the keys", async ({ mount, page }) => { + const question = await longList(mount) + const footer = question.getByRole("checkbox", { name: /Footer/ }) -story("sizes the answers box to each question with the dock's motion off", async ({ mount, page }) => { - const question = await longList(mount) - const options = question.locator('[data-slot="question-options"]') - const rest = await fit(question) - expect(rest.scrolls).toBe(true) - expect(await feathered(question)).toEqual({ top: false, bottom: true }) - - await page.keyboard.press("ArrowRight") - await expect(question.getByRole("radio").first()).toBeFocused() - await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: false }) - expect(await options.evaluate((el) => el.style.height)).toBe("") - expect((await fit(question)).scrolls).toBe(false) - - await page.keyboard.press("ArrowLeft") - await expect(question.getByRole("checkbox").first()).toBeFocused() - await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: true }) - expect((await fit(question)).height).toBeCloseTo(rest.height, 0) -}) + await page.keyboard.press("End") + await page.keyboard.press("ArrowUp") + await expect(footer).toBeFocused() + expect(await clear(footer)).toBe(true) -story.describe("a long list with the question dock's motion on", () => { - story.beforeEach(({ page }) => - page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))), + await page.keyboard.press("Home") + await expect(question.getByRole("checkbox").first()).toBeFocused() + expect((await fit(question)).top).toBe(0) + }) + + story("feathers only the edges of the answers with more past them", async ({ mount }) => { + const question = await longList(mount) + + // Scrolls to a share of the way down the list. + const edges = (share: number) => + question.evaluate(async (root, share) => { + const options = root.querySelector('[data-slot="question-options"]')! + options.scrollTop = Math.round((options.scrollHeight - options.clientHeight) * share) + await new Promise((done) => options.addEventListener("scroll", done, { once: true })) + const mask = options.style.maskImage + + // A gradient down the list may be written without its direction, which is the default. + return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") } + }, share) + + expect(await edges(0.5)).toEqual({ top: true, bottom: true }) + expect(await edges(1)).toEqual({ top: true, bottom: false }) + expect(await edges(0)).toEqual({ top: false, bottom: true }) + }) + + story("leaves a list that fits without feathered edges", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio").first()).toBeFocused() + await expect + .poll(() => question.locator('[data-slot="question-options"]').evaluate((el) => el.style.maskImage)) + .toBe("") + }) + + story( + "caps a long list to the panel in the app's layout, where the dock overflows a zero-height row", + async ({ mount }) => { + const question = await longList(mount) + // As in the session screen, the composer region moves into a zero-height row at the foot of the panel and overflows + // it upward, over the timeline. + await question.evaluate((root) => { + const dock = root.closest('[data-component="session-composer-dock"]')! + const row = document.createElement("div") + row.style.cssText = + "position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 0" + row.style.flexShrink = "0" + dock.replaceWith(row) + row.append(dock) + dispatchEvent(new Event("resize")) + }) + + // Measured against the empty row, the cap would fall to its 240px minimum. + await expect.poll(async () => (await fit(question)).height).toBeGreaterThan(400) + const rest = await fit(question) + expect(rest.within).toBe(true) + expect(rest.scrolls).toBe(true) + }, ) - story("restores a long list without growing past where it rests", async ({ mount }) => { + story("brings the picked answer back into view after switching questions", async ({ mount, page }) => { const question = await longList(mount) + const footer = question.getByRole("checkbox", { name: /Footer/ }) + const title = question.locator('[data-slot="question-header-title"]') + + await page.keyboard.press("End") + await page.keyboard.press("ArrowUp") + await page.keyboard.press(" ") + await expect(footer).toBeChecked() + + await page.keyboard.press("ArrowRight") + await expect(title).toHaveText("2 of 2 questions") + await page.keyboard.press("ArrowLeft") + await expect(title).toHaveText("1 of 2 questions") + await expect(footer).toBeFocused() + expect(await clear(footer)).toBe(true) + }) + + story("sizes the answers box to each question with the dock's motion off", async ({ mount, page }) => { + const question = await longList(mount) + const options = question.locator('[data-slot="question-options"]') const rest = await fit(question) - await question.getByRole("button", { name: "Minimize question" }).click() - await expect(question.getByRole("button", { name: "Restore question" })).toBeVisible() - await expect - .poll(() => - question.locator('[data-slot="question-options"]').evaluate((el) => el.getBoundingClientRect().height), - ) - .toBe(0) + expect(rest.scrolls).toBe(true) + expect(await feathered(question)).toEqual({ top: false, bottom: true }) - const frames = await question.evaluate(async (root) => { - const options = root.querySelector('[data-slot="question-options"]')! - root.querySelector('[data-slot="question-header-actions"] > button:last-of-type')!.click() - - return Array.from({ length: 45 }).reduce>( - async (list) => { - const frames = await list - await new Promise((done) => requestAnimationFrame(done)) - - return [ - ...frames, - { - height: root.getBoundingClientRect().height, - asked: parseFloat(options.style.height) || 0, - given: options.getBoundingClientRect().height, - }, - ] - }, - Promise.resolve([]), - ) - }) + await page.keyboard.press("ArrowRight") + await expect(question.getByRole("radio").first()).toBeFocused() + await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: false }) + expect(await options.evaluate((el) => el.style.height)).toBe("") + expect((await fit(question)).scrolls).toBe(false) + + await page.keyboard.press("ArrowLeft") + await expect(question.getByRole("checkbox").first()).toBeFocused() + await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: true }) + expect((await fit(question)).height).toBeCloseTo(rest.height, 0) + }) + + story.describe("a long list with the question dock's motion on", () => { + story.beforeEach(({ page }) => + page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))), + ) - const heights = frames.map((frame) => frame.height) - expect(heights.every((height, i) => i === 0 || height >= heights[i - 1]! - 0.5)).toBe(true) - expect(Math.max(...heights)).toBeLessThanOrEqual(rest.height + 0.5) - expect(heights.at(-1)).toBeCloseTo(rest.height, 0) - // The answers box is never asked to open past the room it is given, which is what made it snap back at the end. - expect(frames.every((frame) => frame.asked <= frame.given + 0.5)).toBe(true) + story("restores a long list without growing past where it rests", async ({ mount }) => { + const question = await longList(mount) + const rest = await fit(question) + await question.getByRole("button", { name: "Minimize question" }).click() + await expect(question.getByRole("button", { name: "Restore question" })).toBeVisible() + await expect + .poll(() => + question.locator('[data-slot="question-options"]').evaluate((el) => el.getBoundingClientRect().height), + ) + .toBe(0) + + const frames = await question.evaluate(async (root) => { + const options = root.querySelector('[data-slot="question-options"]')! + root.querySelector('[data-slot="question-header-actions"] > button:last-of-type')!.click() + + return Array.from({ length: 45 }).reduce>( + async (list) => { + const frames = await list + await new Promise((done) => requestAnimationFrame(done)) + + return [ + ...frames, + { + height: root.getBoundingClientRect().height, + asked: parseFloat(options.style.height) || 0, + given: options.getBoundingClientRect().height, + }, + ] + }, + Promise.resolve([]), + ) + }) + + const heights = frames.map((frame) => frame.height) + expect(heights.every((height, i) => i === 0 || height >= heights[i - 1]! - 0.5)).toBe(true) + expect(Math.max(...heights)).toBeLessThanOrEqual(rest.height + 0.5) + expect(heights.at(-1)).toBeCloseTo(rest.height, 0) + // The answers box is never asked to open past the room it is given, which is what made it snap back at the end. + expect(frames.every((frame) => frame.asked <= frame.given + 0.5)).toBe(true) + }) }) }) diff --git a/packages/app/component-tests/terminal-focus.spec.ts b/packages/app/component-tests/terminal-focus.spec.ts index b810a006d3ec..e2962676095c 100644 --- a/packages/app/component-tests/terminal-focus.spec.ts +++ b/packages/app/component-tests/terminal-focus.spec.ts @@ -37,3 +37,20 @@ story("a terminal opened without a focus request leaves focus in the composer", await expect(root.getByRole("textbox", { name: "Composer", exact: true })).toBeFocused() }) + +story("disposing a terminal invalidates its retained quote anchor", async ({ page }) => { + await page.evaluate(async (fixture) => { + const { mountTerminalQuote } = await import(fixture) + await mountTerminalQuote() + }, fixture) + const root = page.getByTestId("terminal-quote-fixture") + await root.getByRole("button", { name: "Select output", exact: true }).click() + await page.getByRole("toolbar", { name: "quote.actions", exact: true }).getByRole("button").click() + await expect(root.getByLabel("Quoted output", { exact: true })).toHaveText("Retained terminal output") + await expect(root.getByLabel("Quote placement", { exact: true })).toHaveText("Source") + await root.getByRole("button", { name: "Dispose source", exact: true }).click() + + await expect(root.getByTestId("terminal-quote-source")).toHaveCount(0) + await expect(root.getByLabel("Quote placement", { exact: true })).toHaveText("Tile") + await expect(root.getByLabel("Quoted output", { exact: true })).toHaveText("Retained terminal output") +}) diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts index b145ead3ee77..994570d69467 100644 --- a/packages/app/component-tests/timeline-virtualizer.spec.ts +++ b/packages/app/component-tests/timeline-virtualizer.spec.ts @@ -62,7 +62,7 @@ story("treats the reachable end as the end under page zoom", async ({ page }) => await page.evaluate(async (fixture) => { document.documentElement.style.zoom = "1.1" const { mountTimelineVirtualizer } = await import(fixture) - mountTimelineVirtualizer({ count: 4, rowHeight: 60.3, immediate: true, viewportHeight: 200.5 }) + mountTimelineVirtualizer({ count: 4, rowHeight: 59.3, immediate: true, viewportHeight: 200.5 }) }, fixture) const root = page.getByTestId("timeline-virtualizer-fixture") const content = root.locator("[data-timeline-virtual-content]") @@ -108,8 +108,8 @@ for (const input of [ await expect(content.locator("[data-timeline-key]")).toHaveCount(1) if (input.name === "offset-only") { - // Pinned to the end: the 600px row and the virtualizer's 82px tail, less the 180px viewport. - await expect(root).toHaveAttribute("data-last-scroll-top", "502") + // Pinned to the end: the 600px row and the virtualizer's 86px tail, less the 180px viewport. + await expect(root).toHaveAttribute("data-last-scroll-top", "506") await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 }) await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false") } diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 806fb3aa1c11..e57638b7cb12 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -107,7 +107,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a id: "opencode", name: "OpenCode", models: { - "big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } }, + "big-pickle": { + id: "big-pickle", + name: "Big Pickle", + cost: { input: 0, output: 0 }, + limit: { context: 200_000 }, + }, }, }, ...(state.authorized @@ -117,7 +122,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a integrationID: "opencode-go", name: "OpenCode Go", models: { - "glm-5-go": { id: "glm-5-go", name: "GLM-5 Go", cost: { input: 1, output: 1 }, limit: { context: 200_000 } }, + "glm-5-go": { + id: "glm-5-go", + name: "GLM-5 Go", + cost: { input: 1, output: 1 }, + limit: { context: 200_000 }, + }, }, }, ] @@ -131,7 +141,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a id: "opencode", name: "OpenCode Console", methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }], - connections: state.authorized ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] : [], + connections: state.authorized + ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] + : [], }, { id: "opencode-go", @@ -261,6 +273,165 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a await expect(view.editor).toHaveText("") }) +for (const action of ["tray", "picker"] as const) { + test(`the Go usage limit restores the prompt and dismisses the tray when switching models through the ${action}`, async ({ + page, + context, + }) => { + await page.clock.setFixedTime(T0) + await context.route("https://opencode.ai/**", (route) => route.fulfill({ body: "Go" })) + const sessionID = "ses_go_limit" + const model = { id: "glm-5-go", providerID: "opencode-go" } + + const view = await openSession(page, { + name: "GoLimit", + sessions: [{ id: sessionID, agent: "build", model }], + onFileWrite: () => undefined, + provider: () => ({ + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "big-pickle": { + id: "big-pickle", + name: "Big Pickle", + cost: { input: 0, output: 0 }, + limit: { context: 200_000 }, + }, + }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + models: { + "glm-5-go": { + id: "glm-5-go", + name: "GLM-5 Go", + cost: { input: 1, output: 1 }, + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode", "opencode-go"], + default: { build: "opencode-go/glm-5-go" }, + }), + pageMessages: () => ({ + items: [ + { id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } }, + { id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } }, + ], + }), + }) + + await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() + + // The refusal OpenCode Console sends a Go subscriber at a limit, as core records it. + const body = { + type: "error", + error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" }, + metadata: { workspace: "wrk_go_limit", limitName: "5 hour" }, + } + + await view.push([ + { + id: "evt_go_limit_1", + created: T0 + 2, + type: "session.step.failed", + location: { directory: view.directory }, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { + sessionID, + assistantMessageID: "msg_go_assistant", + error: { + type: "provider.go-limit", + message: "OpenCode Go 5 hour usage limit reached.", + status: 429, + response: { body: JSON.stringify(body) }, + retryAfterMs: 3_600_000, + }, + }, + }, + ]) + + const tray = page.locator('[data-component="session-go-limit-tray"]') + await expect(tray).toContainText(/Go 5 hour usage limit reached\. It resets at .+?\./) + await expect( + page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }), + ).toBeVisible() + await expect(view.editor).toHaveText("Refactor the parser") + await expect(page.getByText("OpenCode Go 5 hour usage limit reached.")).toHaveCount(0) + await expect(page.getByRole("dialog")).toHaveCount(0) + + // Extra Usage is turned on from the workspace's Go page in OpenCode Console. + const popup = page.waitForEvent("popup") + await tray.getByRole("button", { name: "Turn on Extra Usage", exact: true }).click() + await expect(await popup).toHaveURL("https://opencode.ai/console/wrk_go_limit/go") + + if (action === "tray") await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click() + + if (action === "picker") { + await page.locator('[data-action="composer-model"]').click() + const picker = page.getByRole("dialog", { name: "Select model", exact: true }) + await picker.getByRole("combobox", { name: "Search models", exact: true }).fill("Big Pickle") + await picker.getByRole("option", { name: /Big Pickle/ }).click() + } + + await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle") + await expect(tray).toHaveCount(0) + await expect(view.editor).toHaveText("Refactor the parser") + }) +} + +test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => { + await page.clock.setFixedTime(T0) + const sessionID = "ses_chatgpt_limit" + const model = { id: "gpt-5.5", providerID: "openai" } + + const view = await openSession(page, { + name: "ChatGPTLimit", + sessions: [{ id: sessionID, agent: "build", model }], + onFileWrite: () => undefined, + pageMessages: () => ({ + items: [ + { id: "msg_chatgpt_user", type: "user", text: "Refactor the parser", time: { created: T0 } }, + { + id: "msg_chatgpt_assistant", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: T0 + 1 }, + }, + ], + }), + }) + + await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() + + await view.push([ + { + id: "evt_chatgpt_limit_1", + created: T0 + 2, + type: "session.step.failed", + location: { directory: view.directory }, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { + sessionID, + assistantMessageID: "msg_chatgpt_assistant", + error: { + type: "provider.chatgpt-limit", + message: "ChatGPT usage limit reached. Check ChatGPT Settings → Usage for details.", + status: 429, + }, + }, + }, + ]) + + await expect(page.getByRole("dialog", { name: "ChatGPT usage limit reached" })).toBeVisible() +}) + test("a catalog refresh cannot replace an existing session's model", async ({ page }) => { const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } } const beta = { id: "beta", name: "Beta" } diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index e160098bf6d9..cb8ef2fc0473 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -47,12 +47,11 @@ async function open( await expectSessionTitle(page, title) await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - const panel = - input.surface === "settings" - ? page.getByTestId("settings-screen") - : input.surface === "popover" - ? page.getByRole("group", { name: "MCP", exact: true }) - : page.getByRole("dialog", { name: "MCPs", exact: true }) + const panel = { + settings: page.getByTestId("settings-screen"), + popover: page.getByRole("group", { name: "MCP", exact: true }), + dialog: page.getByRole("dialog", { name: "MCPs", exact: true }), + }[input.surface] const show = async () => { if (input.surface === "dialog") return page.keyboard.press("Control+;") @@ -255,6 +254,49 @@ for (const surface of ["dialog"] as const) { }) } +test("the MCP list shrinks with matches and keeps its search anchored while scrolling", async ({ page }) => { + const view = await open(page, { + surface: "dialog", + mcp: ["figma-desktop", ...Array.from({ length: 24 }, (_, index) => `server-${String(index).padStart(2, "0")}`)].map( + (name) => ({ name, status: { status: "disabled" } }), + ), + }) + + const input = view.panel.getByRole("combobox", { name: "Search MCPs", exact: true }) + const container = page.locator('[data-component="dialog-v2"] [data-slot="dialog-container"]') + + const geometry = () => + container.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + inputTop: element.querySelector('[role="combobox"]')!.getBoundingClientRect().top, + })) + + await expect(input).toBeFocused() + await expect(view.panel.getByRole("option")).toHaveCount(25) + await input.hover() + const initial = await geometry() + + await input.fill("figma-desktop") + await expect(view.panel.getByRole("option")).toHaveCount(1) + await expect.poll(async () => (await geometry()).height).toBeLessThan(initial.height - 100) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + + await input.clear() + await expect(view.panel.getByRole("option")).toHaveCount(25) + const scroll = view.panel.locator('[data-slot="search-list-scroll"]') + + await expect.poll(() => scroll.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).height).toBeLessThanOrEqual(480) + await input.press("End") + const last = view.panel.getByRole("option", { name: "server-23", exact: true }) + + await expect(last).toHaveAttribute("aria-selected", "true") + await expect(last).toBeInViewport() + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + await expect(input).toBeFocused() +}) + test("toggling an MCP from the dialog keeps it highlighted", async ({ page }) => { const connected = new Set() const names = ["alpha-mcp", "figma-desktop", "zeta-mcp"] diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index f4dbba44f0d9..d69d13418d36 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -191,6 +191,59 @@ test("the combined location picker shows checkout branches and chooses the base .toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })]) }) +test("the checkout branch opens the current project's worktree step", async ({ page }) => { + const existing = "C:/OpenCode/BranchWorktrees/.worktrees/quiet-river" + + const view = await openDraft(page, { + name: "BranchWorktrees", + vcs: (directory) => ({ current: directory === existing ? "quiet-river-branch" : "dev", default: "main" }), + worktrees: () => [{ directory: "C:/OpenCode/BranchWorktrees" }, { directory: existing, strategy: "git" }], + }) + + const screen = page.locator('[data-component="new-session"]') + const branch = screen.getByRole("button", { name: "dev", exact: true }) + const dialog = page.getByRole("dialog") + const search = dialog.getByRole("combobox", { name: "Search worktrees", exact: true }) + const options = dialog.getByRole("option") + const back = dialog.getByRole("button", { name: "Back to projects", exact: true }) + + await branch.click() + await expect(search).toBeFocused() + await expect(back).toHaveText("Run session in") + await expect(options.locator(".command-palette-title")).toHaveText([ + "BranchWorktrees", + "New worktree", + "quiet-river", + ]) + await expect(options.nth(0)).toHaveAttribute("aria-selected", "true") + + // Stepping back lands on the projects step with the drilled project active, as after drilling in with →. + await search.press("Backspace") + const projects = dialog.getByRole("combobox", { name: "Search projects", exact: true }) + await expect(projects).toBeFocused() + await expect(back).toHaveCount(0) + await expect(options.filter({ hasText: "BranchWorktrees" })).toHaveAttribute("aria-selected", "true") + await projects.press("Escape") + await expect(dialog).toHaveCount(0) + await expect(view.editor).toBeFocused() + + // Escape also steps back first, then closes. + await branch.focus() + await page.keyboard.press("Enter") + await expect(search).toBeFocused() + await search.press("Escape") + await expect(projects).toBeFocused() + await projects.press("Escape") + await expect(dialog).toHaveCount(0) + + await branch.click() + await options.filter({ hasText: "quiet-river" }).click() + await expect(dialog).toHaveCount(0) + await expect(newSessionLocation(page)).toContainText("quiet-river") + await expect(screen.getByRole("button", { name: "quiet-river-branch", exact: true })).toBeVisible() + await expect(view.editor).toBeFocused() +}) + test("a pending branch picker can be dismissed before its results arrive", async ({ page }) => { const listed = Promise.withResolvers() diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index 47e0261f6b14..00141a7a520f 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -1351,6 +1351,49 @@ test("video files stream into the viewer behind a loading state, open into the p expect(await page.evaluate(() => document.fullscreenElement)).toBeNull() }) +test("review file navigation focuses the arrow for the keyboard direction", async ({ page }) => { + await openSession(page, { + name: "ReviewArrowFocus", + vcsDiff: [fileDiff("src/alpha.ts"), fileDiff("src/beta.ts"), fileDiff("src/gamma.ts")], + seed: { + panes: { ses_reviewarrowfocus: { review: true } }, + storage: { + ...reviewTabs("C:/OpenCode/ReviewArrowFocus", "ses_reviewarrowfocus"), + "opencode.global.dat:review-panel-v2": { sidebarOpened: false }, + }, + }, + }) + + const panel = page.locator("#review-panel") + const file = panel.locator('[data-slot="session-review-v2-file-name"]') + const previous = panel.getByRole("button", { name: "Previous file", exact: true }) + const next = panel.getByRole("button", { name: "Next file", exact: true }) + + await expect(file).toHaveText("alpha.ts") + await previous.click() + await expect(file).toHaveText("gamma.ts") + await page.keyboard.press("ArrowRight") + await expect(file).toHaveText("alpha.ts") + await expect(next).toBeFocused() + await page.keyboard.press("Enter") + await expect(file).toHaveText("beta.ts") + await page.keyboard.press("ArrowLeft") + await expect(file).toHaveText("alpha.ts") + await expect(previous).toBeFocused() + await next.click() + await expect(file).toHaveText("beta.ts") + await expect(next).toBeFocused() + + const home = page.getByRole("button", { name: "Home", exact: true }) + await home.focus() + await page.keyboard.press("ArrowRight") + await expect(file).toHaveText("gamma.ts") + await expect(home).toBeFocused() + await page.keyboard.press("ArrowLeft") + await expect(file).toHaveText("beta.ts") + await expect(home).toBeFocused() +}) + test("restores review state and the side-panel tab per session", async ({ page }) => { const sessions = [ { id: "ses_review_state_a", title: "Alpha review state" }, diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 2b5086d217e2..9d3314da91a4 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -667,6 +667,14 @@ test("editing drops a file whose mention was deleted and keeps unmentioned conte test("editing a comment-only prompt keeps its notes once", async ({ page }) => { const note = "The user made the following comment regarding line 2 of /repo/app.ts: check the guard" const mock = createQueueMock([note]) + mock.rows[0].payload.files = [ + { + data: "aGk=", + mime: "text/plain", + source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" }, + name: "app.ts", + }, + ] mock.rows[0].payload.metadata = { displayText: "", comments: [ @@ -679,8 +687,8 @@ test("editing a comment-only prompt keeps its notes once", async ({ page }) => { ], } const view = await openQueue(page, mock) - // With no display text, the editor shows the note itself, so the edit owns it as text. - await view.rows.getByText(note, { exact: true }).click() + // The queue counts the context file; the existing editor still loads its model-visible note as text. + await view.rows.getByRole("button", { name: "1 attachment", exact: true }).click() await expect(view.input).toHaveText(note) await view.input.press("End") await view.input.pressSequentially(", please") @@ -1171,10 +1179,8 @@ for (const delivery of ["steer", "queue"] as const) { await expect(bubble).toHaveCSS("color", colors.pending.text) await expect(bubble).toHaveCSS("transition-duration", "0s") await expect(pending.locator('[data-slot="user-message-copy-wrapper"]')).toHaveCSS("opacity", "1") - // Pending stands in for the time of a steer that has not been sent. - await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending\s*·\s*Queue Model$/, { - useInnerText: true, - }) + // The steer keeps U1's agent and model, so Pending is all its meta line says. + await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending$/, { useInnerText: true }) await bubble.evaluate((element) => (element.dataset.deliveryMarker = "pending")) } @@ -1256,7 +1262,7 @@ for (const delivery of ["steer", "queue"] as const) { if (colors) { await expect(bubble).toHaveAttribute("data-delivery-marker", "pending") - await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model\s*·\s*Just now$/, { + await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model$/, { useInnerText: true, }) await expect(bubble).toHaveCSS("background-color", colors.delivered.background) diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index b4cd9fe1900e..8507ad00044b 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -190,6 +190,76 @@ test("new tab selection and closing stay in the focused pane", async ({ page }) await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") }) +test("closing the last New tab closes the side region, and only a New tab lists the launchers", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await openSession(page, { name: "LastNewTab", seed: { panes: { ses_lastnewtab: { review: true } } } }) + + const panel = page.locator("#review-panel") + const toggle = page.getByRole("button", { name: "Toggle review", exact: true }) + const newTab = panel.getByRole("tab", { name: "New tab", exact: true }) + const review = panel.getByRole("button", { name: /^Review/ }) + + // An empty side region opens on a New tab listing the launchers. + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect(review).toBeVisible() + + // Closing it leaves no tab, so the region closes rather than listing the launchers without one. + await newTab.locator("..").getByRole("button", { name: "Close tab" }).click() + await expect(toggle).toHaveAttribute("aria-expanded", "false") + + await toggle.click() + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect(review).toBeVisible() +}) + +test("tabs in a narrow side strip shrink to keep their close buttons, and a tab's menu closes the others", async ({ + page, +}) => { + await page.setViewportSize({ width: 1000, height: 700 }) + const directory = "C:/OpenCode/NarrowStrip" + const key = `local\u0000${base64Encode(directory)}/ses_narrowstrip` + const files = ["a.md", "a-file-name-long-enough-to-overflow-the-side-strip-by-itself.md", "b.md"] + + await openSession(page, { + name: "NarrowStrip", + fileList: () => [], + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + seed: { + panes: { ses_narrowstrip: { review: true, sessionWidth: 640 } }, + storage: { + "opencode.global.dat:layout": { + sessionTabs: { [key]: { all: files.map((file) => `file://${file}`), active: `file://${files[1]}` } }, + }, + }, + }, + }) + + const side = page.locator('[data-pane-content="side"]') + const add = side.getByRole("button", { name: "Add tab", exact: true }) + await expect(side.getByRole("tab", { name: files[1], exact: true })).toHaveAttribute("aria-selected", "true") + + // Every tab's close button stays left of "+", instead of the strip scrolling it away under its edge fade. + const end = (await add.boundingBox())!.x + const closes = side.locator('[data-pane-strip="side"] [data-slot="tabs-trigger-close-button"]') + await expect(closes).toHaveCount(files.length) + + for (const close of await closes.all()) { + const box = (await close.boundingBox())! + expect(box.x + box.width).toBeLessThanOrEqual(end) + } + + await side.getByRole("tab", { name: "b.md", exact: true }).click({ button: "right" }) + await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click() + await expect(side.getByRole("tab")).toHaveCount(1) + await expect(side.getByRole("tab", { name: "b.md", exact: true })).toHaveAttribute("aria-selected", "true") + + // With nothing else to close, the menu offers only the tab itself. + await side.getByRole("tab", { name: "b.md", exact: true }).click({ button: "right" }) + await expect(page.getByRole("menuitem", { name: "Close other tabs", exact: true })).toBeDisabled() + await page.getByRole("menuitem", { name: "Close tab", exact: true }).click() + await expect(side.getByRole("tab", { name: "b.md", exact: true })).toHaveCount(0) +}) + // A DOM mark survives only while the same node stays mounted. function mark(locator: Locator) { return locator.evaluate((element) => element.setAttribute("data-e2e-mounted", "original")) diff --git a/packages/app/e2e/regression/terminal-input.spec.ts b/packages/app/e2e/regression/terminal-input.spec.ts index 81c95c9e527e..5a69c2d885c8 100644 --- a/packages/app/e2e/regression/terminal-input.spec.ts +++ b/packages/app/e2e/regression/terminal-input.spec.ts @@ -85,7 +85,55 @@ test("quotes selected terminal output into the composer", async ({ page }) => { await expect(chips).toHaveCount(1) await expect(chips).toContainText("Terminal") await expect(toolbar).toBeHidden() + const comment = page.locator('[data-component="composer-quote-comment"] textarea') + await expect(comment).toBeFocused() + await comment.fill("Investigate this output") + + const atTile = async () => { + const popover = await page.locator('[data-component="composer-quote-comment"]').boundingBox() + const tile = await chips.nth(0).boundingBox() + + return ( + popover && tile && Math.abs(popover.x - tile.x) < 1 && Math.abs(popover.y + popover.height - (tile.y - 8)) < 1 + ) + } + + await expect.poll(atTile).toBe(false) + // Cached terminals can hide without a write or grid resize; the unsaved editor still follows its tile. + await page.getByRole("button", { name: "Add tab", exact: true }).click() + await expect(terminal).toBeHidden() + await expect.poll(atTile).toBe(true) + await expect(comment).toHaveValue("Investigate this output") + await page.locator("#review-panel").getByRole("tab", { name: "Terminal 1", exact: true }).click() + await expect(terminal).toBeVisible() + await expect.poll(atTile).toBe(false) + await expect(comment).toHaveValue("Investigate this output") + + // Any output invalidates terminal row identity; the same draft stays open beside its composer tile. + pty.send(Array.from({ length: 80 }, (_, index) => `output row ${index}\r\n`).join("")) + await expect.poll(atTile).toBe(true) + await expect(comment).toHaveValue("Investigate this output") + await comment.fill("") + await comment.press("Enter") + await expect(comment).toHaveCount(0) + await expect(editor).toBeFocused() + + // The plugin's Alt path skips the editor too, and either quote can still be commented from its tile. + const next = await canvas.boundingBox() + + if (!next) throw new Error("The terminal has no canvas") + await page.mouse.move(next.x + 2, next.y + 4) + await page.mouse.down() + await page.mouse.move(next.x + 320, next.y + 26, { steps: 8 }) + await page.mouse.up() + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) + await expect(chips).toHaveCount(2) + await expect(comment).toHaveCount(0) await expect(editor).toBeFocused() + await chips.nth(0).click() + await comment.fill("Why did the build fail?") + await comment.press("Enter") + await expect(chips.nth(0)).toContainText("Why did the build fail?") }) test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { diff --git a/packages/app/e2e/regression/timeline-cache.spec.ts b/packages/app/e2e/regression/timeline-cache.spec.ts index 54655b068028..819432eeaef8 100644 --- a/packages/app/e2e/regression/timeline-cache.spec.ts +++ b/packages/app/e2e/regression/timeline-cache.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test" -import type { SessionMessageInfo } from "@opencode/client/promise" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" import { timelinePresets } from "@opencode/session-ui/timeline/detail" import { expected, messages } from "../utils/markdown-sessions" import { expectPath, holdRoute, seed, sessionHref, type SeedInput } from "../utils/app" @@ -66,7 +66,7 @@ for (const width of [1440, 390]) { // The gap above the composer is part of the tail: the status lane, then the activity pill and its gap. const spacer = page.locator('[data-timeline-row="bottom-spacer"]') await expect(spacer).toBeVisible() - expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(82) + expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(86) } finally { release.resolve() } @@ -169,7 +169,7 @@ test("reloads a transcript only after inline file links have their final styling } }) -test("restores verified inline paths before painting after a workspace tab switch", async ({ page }) => { +test("keeps verified inline paths across a workspace tab switch", async ({ page }) => { const directory = "C:/OpenCode/InlinePaths" const other = "C:/OpenCode/OtherInlinePaths" const sourceID = "ses_inline_source" @@ -177,7 +177,8 @@ test("restores verified inline paths before painting after a workspace tab switc const queries: { query: string; directory: string }[] = [] const existing = new Set([directory]) await page.clock.setFixedTime(new Date("2026-10-21T00:00:00Z")) - await mockWorkspace(page, { + + const workspace = await mockWorkspace(page, { name: "InlinePaths", directory, project: { sandboxes: [other] }, @@ -204,60 +205,43 @@ test("restores verified inline paths before painting after a workspace tab switc return existing.has(input.directory) ? ["src/file.ts"] : [] }, }) + await page.goto(sessionHref(sourceID)) - const source = page.locator(`[data-timeline-part-id="answer_${sourceID}:text:0"] code`) + const source = part(page, sourceID).locator("code") await expect(source).toHaveAttribute("data-inline-code-kind", "path") const original = await source.elementHandle() await tab(page, targetID).click() - const target = page.locator(`[data-timeline-part-id="answer_${targetID}:text:0"] code`) + const target = part(page, targetID).locator("code") await expect(target).toHaveText("src/file.ts") await expect.poll(() => queries.some((query) => query.directory === other)).toBe(true) await expect(target).not.toHaveAttribute("data-inline-code-kind") expect(await original!.evaluate((node) => node.isConnected)).toBe(false) - const insertion = await page.evaluateHandle((id) => { - type Insertion = { kind?: string | null } - - const state: Insertion = {} - - const observer = new MutationObserver(() => { - const code = document.querySelector(`[data-timeline-part-id="answer_${id}:text:0"] code`) - - if (!code) return - state.kind = code.getAttribute("data-inline-code-kind") - observer.disconnect() - }) - - observer.observe(document, { childList: true, subtree: true }) - - return state - }, sourceID) - const count = queries.length const held = await holdRoute(page, /\/api\/fs\/find\?/) try { await tab(page, sourceID).click() await expect(source).toBeVisible() - // The other workspace disposes the old timeline, so this must exercise a real Markdown remount. - expect(await source.evaluate((node, original) => node === original, original)).toBe(false) - expect(await insertion.evaluate((state) => state.kind)).toBe("path") + // The source workspace's timeline is kept while the other workspace is selected, so its links need no new lookup. + expect(await source.evaluate((node, original) => node === original, original)).toBe(true) + await expect(source).toHaveAttribute("data-inline-code-kind", "path") expect(held.requests).toHaveLength(0) expect(queries).toHaveLength(count) } finally { held.release() - await insertion.dispose() } - // Expired positives keep their styling while revalidation is pending; negatives must discover new files. - await tab(page, targetID).click() - await expect(target).toBeVisible() + // Markdown that renders again repeats its lookups: expired positives keep their styling while revalidation is + // pending, and negatives must discover new files. Link results last as long as the session screen, which going + // Home unmounts, so the text changes in place. await page.clock.setFixedTime(new Date("2026-10-21T00:01:01Z")) const refresh = await holdRoute(page, /\/api\/fs\/find\?/) try { - await tab(page, sourceID).click() + await workspace.push([textEnded(sourceID, directory)]) await refresh.arrived + await expect(part(page, sourceID)).toContainText("again") await expect(source).toHaveAttribute("data-inline-code-kind", "path") } finally { refresh.release() @@ -265,6 +249,9 @@ test("restores verified inline paths before painting after a workspace tab switc existing.add(other) await tab(page, targetID).click() + await expect(target).toBeVisible() + await workspace.push([textEnded(targetID, other)]) + await expect(part(page, targetID)).toContainText("again") await expect(target).toHaveAttribute("data-inline-code-kind", "path") }) @@ -355,7 +342,7 @@ test("focuses Find in the selected cached timeline", async ({ page }) => { await expect(search).toHaveCount(0) }) -test("disposes the old workspace's shell while destination history is loading", async ({ page }) => { +test("keeps the old workspace's shell on its own Location while destination history is loading", async ({ page }) => { const destination = "C:/OpenCode/OtherProject" const requested = Promise.withResolvers() const release = Promise.withResolvers() @@ -430,7 +417,7 @@ test("disposes the old workspace's shell while destination history is loading", output.text += "Output after returning\n" await tab(page, fixture.sourceID).click() await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning") - expect(await original!.evaluate((element) => element.isConnected)).toBe(false) + expect(await original!.evaluate((element) => element.isConnected)).toBe(true) expect(reads.length).toBeGreaterThan(1) expect(reads.every((directory) => directory === fixture.directory)).toBe(true) } finally { @@ -615,6 +602,21 @@ function placeholderHistory(id: string) { } } +function part(page: Page, sessionID: string) { + return page.locator(`[data-timeline-part-id="answer_${sessionID}:text:0"]`) +} + +function textEnded(sessionID: string, directory: string): OpenCodeEvent { + return { + id: `evt_text_${sessionID}`, + created: 4, + type: "session.text.ended", + location: { directory }, + durable: { aggregateID: sessionID, seq: 0, version: 1 }, + data: { sessionID, assistantMessageID: `answer_${sessionID}`, ordinal: 0, text: "Inspect `src/file.ts` again." }, + } +} + function tab(page: Page, sessionID: string) { return page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`) } diff --git a/packages/app/e2e/regression/timeline-geometry.spec.ts b/packages/app/e2e/regression/timeline-geometry.spec.ts index 0ba5bcb5ca15..e1d41a7cf6c0 100644 --- a/packages/app/e2e/regression/timeline-geometry.spec.ts +++ b/packages/app/e2e/regression/timeline-geometry.spec.ts @@ -747,13 +747,13 @@ test("groups consecutive sent prompts under the last one's meta line, with each return Math.round(below.y - above.y - above.height) }) .toBe(4) - await expect(first.locator('[data-slot="user-message-meta-tail"]')).toBeHidden() - await expect(last.locator('[data-slot="user-message-meta-tail"]')).toBeVisible() + await expect(first.locator('[data-slot="user-message-copy-wrapper"]')).toBeHidden() + await expect(last.locator('[data-slot="user-message-copy-wrapper"]')).toBeVisible() // The first message keeps its own actions, shown beside its bubble on hover. await bubble(first).hover() await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1") - await expect(first.getByRole("button", { name: "Copy message" })).toHaveCount(1) + await expect(first.getByRole("button", { name: "More actions" })).toHaveCount(1) }) test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => { diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index e1812ce1c64f..707e47552e0c 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test" import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode/client/promise" import { timelinePresets } from "@opencode/session-ui/timeline/detail" import { createTwoFilesPatch } from "diff" +import { readSessionDraftText } from "../utils/drafts" import { assistantID, assistantMessage, @@ -1961,12 +1962,16 @@ test.describe("shell completion", () => { test("quotes plain timeline text into the composer, with or without a comment", async ({ page }) => { const prompts: string[] = [] + const metadata: unknown[] = [] await setupTimeline(page, { messages: [ userMessage([userText("Explain the build", { id: "prt_quote_user" })]), assistantMessage([textPart("prt_quote_answer", "It has **two** steps:\n\n- compile\n- bundle")]), ], - onPrompt: (input) => void prompts.push(String(input.body.text)), + onPrompt: (input) => { + prompts.push(String(input.body.text)) + metadata.push(input.body.metadata) + }, }) const user = '[data-slot="user-message-text"]' @@ -1996,21 +2001,37 @@ test("quotes plain timeline text into the composer, with or without a comment", const chips = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') await expect(chips).toHaveCount(1) await expect(chips).toContainText("Explain the build") + const comment = page.locator('[data-component="composer-quote-comment"] textarea') + await expect(comment).toBeFocused() + await expect(comment).toHaveAttribute("placeholder", "Add an optional comment...") + const popover = page.locator('[data-component="composer-quote-comment"]') + await expect(popover.getByRole("button")).toHaveCount(1) + await expect(popover.getByRole("button", { name: "Done", exact: true })).toBeVisible() + await expect( + popover.getByRole("button", { name: "Done", exact: true }).locator('span[aria-hidden="true"]'), + ).not.toHaveCSS("mask-image", "none") + await popover.getByRole("button", { name: "Done", exact: true }).click() + await expect(page.locator('[data-component="composer-editor"]')).toBeFocused() + await expect(chips).toHaveCount(1) // A quote's chip takes its comment; saving an emptied comment clears it again. await select(answer, answer) await toolbar.getByRole("button", { name: "Quote", exact: true }).click() await expect(chips).toHaveCount(2) - const comment = page.locator('[data-component="composer-quote-comment"] textarea') - await chips.nth(1).click() + await expect(comment).toBeFocused() await comment.fill("Why three?") - await comment.press("Enter") + await expect(popover.getByRole("button")).toHaveCount(1) + await expect(popover.getByRole("button", { name: "Add comment", exact: true })).toBeVisible() + await popover.getByRole("button", { name: "Add comment", exact: true }).click() await expect(comment).toHaveCount(0) await expect(chips.nth(1)).toContainText("Why three?") await chips.nth(1).click() await expect(comment).toHaveValue("Why three?") + await expect(popover.getByRole("button")).toHaveCount(1) + await expect(popover.getByRole("button", { name: "Save", exact: true })).toBeVisible() await comment.fill("") - await comment.press("Enter") + await expect(popover.getByRole("button", { name: "Save", exact: true })).toBeEnabled() + await popover.getByRole("button", { name: "Save", exact: true }).click() await expect(chips.nth(1)).not.toContainText("Why three?") await chips.nth(1).click() await comment.fill("Why two?") @@ -2023,7 +2044,9 @@ test("quotes plain timeline text into the composer, with or without a comment", // The shell turns selection off, so select all in the timeline selects its conversation and offers to quote it. await page.locator(answer).getByText("bundle", { exact: true }).click() - await page.keyboard.press("ControlOrMeta+a") + await page.keyboard.press( + await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta+a" : "Control+a")), + ) await expect(toolbar).toBeVisible() await page.keyboard.press("Escape") await expect(toolbar).toHaveCount(0) @@ -2048,17 +2071,26 @@ test("quotes plain timeline text into the composer, with or without a comment", "The user made the following comment regarding this quote: Why two?", ].join("\n"), ]) + expect(metadata).toEqual([ + expect.objectContaining({ + comments: [ + { type: "quote", text: "Explain the build\n\nIt has two steps:\ncompile\nbundle" }, + { type: "quote", text: "It has two steps:\ncompile\nbundle", comment: "Why two?" }, + ], + }), + ]) await expect(chips).toHaveCount(0) }) -test("highlights a quote's source only while its first comment is written", async ({ page }) => { +test("highlights the exact duplicate passage whenever its quote comment is open", async ({ page }) => { let reject = true await setupTimeline(page, { + seedHistory: true, messages: [ userMessage(), assistantMessage([ - textPart("prt_highlight_first", "The same **quoted** **words**."), - textPart("prt_highlight_second", "The same **quoted** **words**."), + textPart("prt_highlight_first", "The same **quoted** **words**. The same **quoted** **words**."), + textPart("prt_highlight_second", "The same **quoted** **words**. The same **quoted** **words**."), ]), ], onPrompt: () => { @@ -2069,43 +2101,79 @@ test("highlights a quote's source only while its first comment is written", asyn const secondID = renderedPartID("prt_highlight_second") const first = page.locator(`[data-timeline-part-id="${firstID}"] [data-slot="text-part-body"]`) const second = page.locator(`[data-timeline-part-id="${secondID}"] [data-slot="text-part-body"]`) - await expect(second).toHaveText("The same quoted words.") + await expect(second).toHaveText("The same quoted words. The same quoted words.") const highlights = () => page.evaluate(() => ["timeline-quote-source"].map((name) => - [...(CSS.highlights.get(name) ?? [])].map((range) => ({ - part: range.startContainer.parentElement - ?.closest("[data-timeline-part-id]") - ?.getAttribute("data-timeline-part-id"), - text: range.toString(), - })), + [...(CSS.highlights.get(name) ?? [])].map((range) => { + const body = range.startContainer.parentElement?.closest('[data-slot="text-part-body"]') + + if (!body) throw new Error("Highlighted quote has no source body") + + const prefix = document.createRange() + prefix.selectNodeContents(body) + prefix.setEnd(range.startContainer, range.startOffset) + + return { + part: body.closest("[data-timeline-part-id]")?.getAttribute("data-timeline-part-id"), + text: range.toString(), + start: prefix.toString().length, + end: prefix.toString().length + range.toString().length, + } + }), ), ) const selectionCollapsed = () => page.evaluate(() => window.getSelection()?.isCollapsed ?? true) - const select = (target: Locator) => - target.evaluate((element) => { + const select = (target: Locator, start: number) => + target.evaluate((element, start) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const range = document.createRange() - range.selectNodeContents(element) + let offset = 0 + let node = walker.nextNode() + + while (node) { + const end = offset + (node.textContent?.length ?? 0) + + if (start >= offset && start < end) range.setStart(node, start - offset) + + if (start + 22 > offset && start + 22 <= end) { + range.setEnd(node, start + 22 - offset) + break + } + + offset = end + node = walker.nextNode() + } + window.getSelection()?.removeAllRanges() window.getSelection()?.addRange(range) - }) + }, start) const toolbar = page.getByRole("toolbar", { name: "Quote selection" }) const chips = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') const editor = page.locator('[data-component="composer-editor"]') const comment = page.locator('[data-component="composer-quote-comment"] textarea') - const firstRange = { part: firstID, text: "The same quoted words." } + + const jump = page.locator('[data-component="composer-quote-comment"]').getByRole("button", { + name: "Jump to quote", + exact: true, + }) + + const firstRange = { part: firstID, text: "The same quoted words.", start: 0, end: 22 } + const secondRange = { part: secondID, text: "The same quoted words.", start: 23, end: 45 } + const scroll = page.locator('[data-slot="session-timeline-scroll"] > [data-scrollable]') await scroll.focus() - await select(first) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) + await select(first, 0) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() await expect(comment).toBeFocused() await comment.fill("Keep this passage visible while I write.") await expect.poll(highlights).toEqual([[firstRange]]) + await expect(jump).toHaveCount(0) for (const palette of [ { colorScheme: "dark" as const, background: "color(srgb 0.635294 0.737255 1 / 0.25)" }, @@ -2135,8 +2203,8 @@ test("highlights a quote's source only while its first comment is written", asyn await expect(comment).toHaveCount(0) await expect.poll(highlights).toEqual([[]]) - await select(second) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await select(second, 23) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) await expect(chips).toHaveCount(2) await expect(editor).toBeFocused() await expect.poll(selectionCollapsed).toBe(true) @@ -2148,13 +2216,34 @@ test("highlights a quote's source only while its first comment is written", asyn expect(await scroll.evaluate((element) => element.scrollTop)).toBe(before) await expect.poll(highlights).toEqual([[]]) - // Editing an existing quote's comment shows no source highlight. + // Reopening either identical quote highlights its own part and exact occurrence, never a text search match. await chips.nth(0).click() await expect(comment).toBeFocused() await expect(comment).toHaveValue("Keep this passage visible while I write.") - await expect.poll(highlights).toEqual([[]]) + await expect.poll(highlights).toEqual([[firstRange]]) + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(before) await comment.press("Escape") await expect(comment).toHaveCount(0) + await expect.poll(highlights).toEqual([[]]) + await chips.nth(1).click() + await expect(comment).toBeFocused() + await expect.poll(highlights).toEqual([[secondRange]]) + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(before) + await comment.press("Escape") + await expect.poll(highlights).toEqual([[]]) + + // Reopening a short quote never navigates to its offscreen source. + await scroll.hover({ position: { x: 20, y: 100 } }) + await page.mouse.wheel(0, -10000) + await expect(second).not.toBeInViewport() + const offscreenTop = await scroll.evaluate((element) => element.scrollTop) + await chips.nth(1).click() + await expect(comment).toBeFocused() + await comment.fill("An unsaved short-quote comment") + await expect(second).not.toBeInViewport() + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(offscreenTop) + await comment.press("Escape") + await expect(chips.nth(1)).not.toContainText("An unsaved short-quote comment") await page .locator('[data-component="composer-attachments"]') @@ -2208,7 +2297,7 @@ test("keeps the native selection height after quoting wrapped and formatted text await page .getByRole("toolbar", { name: "Quote selection" }) .getByRole("button", { name: "Quote", exact: true }) - .click({ modifiers: ["Alt"] }) + .click() await expect(page.locator('[data-component="composer-quote-comment"] textarea')).toBeFocused() await page.setViewportSize({ width: 700, height: 900 }) @@ -2224,14 +2313,35 @@ test("keeps the native selection height after quoting wrapped and formatted text ), ) .toBe(true) + + // The source-open preview is compact; editing the attached quote reveals up to four lines of the same passage. + const popover = page.locator('[data-component="composer-quote-comment"]') + const preview = popover.locator('[data-slot="line-comment-v2-preview"]') + + const previewLines = () => + preview.evaluate( + (element) => element.getBoundingClientRect().height / parseFloat(getComputedStyle(element).lineHeight), + ) + + await expect.poll(previewLines).toBeCloseTo(2, 1) + const quotedText = await preview.innerText() + await popover.getByRole("button", { name: "Done", exact: true }).click() + const chip = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') + await expect(chip).toHaveCount(1) + await chip.click() + await expect(popover.locator("textarea")).toBeFocused() + await expect(preview).toHaveText(quotedText) + await expect.poll(previewLines).toBeCloseTo(4, 1) }) -test("reveals a pending quote's source and restores its retained draft after its row remounts", async ({ page }) => { - await setupTimeline(page, { +test("retains a long quote's source across reload without scrolling when its editor opens", async ({ page }) => { + const passage = `Keep this exact source highlighted. ${Array.from({ length: 60 }, (_, index) => `source-word-${index}`).join(" ")}` + + const timeline = await setupTimeline(page, { seedHistory: true, messages: [ userMessage(), - assistantMessage([textPart("prt_quote_remount", "Keep this exact source highlighted.")]), + assistantMessage([textPart("prt_quote_remount", passage)]), userMessage([userText("Continue with the next step.")], { id: "msg_quote_following_user" }), assistantMessage([textPart("prt_quote_following_answer", "The next step is ready.")], { id: "msg_quote_following_answer", @@ -2239,9 +2349,11 @@ test("reveals a pending quote's source and restores its retained draft after its }), ], }) + const partID = renderedPartID("prt_quote_remount") + const source = page.locator(`[data-timeline-part-id="${partID}"] [data-slot="text-part-body"]`) - await expect(source).toHaveText("Keep this exact source highlighted.") + await expect(source).toHaveText(passage) await source.evaluate((element) => { const range = document.createRange() range.selectNodeContents(element) @@ -2251,7 +2363,7 @@ test("reveals a pending quote's source and restores its retained draft after its await page .getByRole("toolbar", { name: "Quote selection" }) .getByRole("button", { name: "Quote", exact: true }) - .click({ modifiers: ["Alt"] }) + .click() const chip = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') const comment = page.locator('[data-component="composer-quote-comment"] textarea') await expect(comment).toBeFocused() @@ -2264,31 +2376,14 @@ test("reveals a pending quote's source and restores its retained draft after its ), ) - await expect.poll(highlighted).toEqual(["Keep this exact source highlighted."]) + await expect.poll(highlighted).toEqual([passage]) const scroll = page.locator('[data-slot="session-timeline-scroll"] > [data-scrollable]') - await scroll.hover({ position: { x: 20, y: 100 } }) - await page.mouse.wheel(0, -10000) - await expect(source).toHaveCount(0) - const top = await scroll.evaluate((element) => element.scrollTop) - await chip.hover() - expect(await scroll.evaluate((element) => element.scrollTop)).toBe(top) - - // Clicking the pending card closes its open comment, and reveals the source before it does. - await chip.click() + await comment.fill("Retained quote comment") + await comment.press("Enter") await expect(comment).toHaveCount(0) - await expect(source).toBeInViewport() - await expect.poll(highlighted).toEqual([]) - - // An existing quote's card edits its comment without moving the timeline to its source. - await scroll.hover({ position: { x: 20, y: 100 } }) - await page.mouse.wheel(0, -10000) - await expect(source).toHaveCount(0) - await chip.click() - await expect(comment).toBeFocused() - await expect(source).toHaveCount(0) + await expect(chip).toContainText("Retained quote comment") await expect.poll(highlighted).toEqual([]) - await comment.press("Escape") const href = new URL(page.url()).pathname await page.getByRole("button", { name: "Home", exact: true }).click() @@ -2298,6 +2393,171 @@ test("reveals a pending quote's source and restores its retained draft after its await expect(chip).toContainText("Keep this exact source highlighted.") await expect.poll(highlighted).toEqual([]) await expect(comment).toHaveCount(0) + + // A fresh document must recover the local source anchor, not just the quote's displayed text. + const editor = page.locator('[data-component="composer-editor"]') + await editor.fill("Retained composer draft") + // Reload only after the editor's input has reached the durable draft, not merely its DOM. + await expect.poll(() => readSessionDraftText(page, sessionID)).toBe("Retained composer draft") + await page.reload() + await timeline.transport.waitForConnection() + await expect(editor).toHaveText("Retained composer draft") + await expect(chip).toContainText("Retained quote comment") + await scroll.hover({ position: { x: 20, y: 100 } }) + await page.mouse.wheel(0, -10000) + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBe(0) + await expect(source).not.toBeInViewport() + const reloadedTop = await scroll.evaluate((element) => element.scrollTop) + await chip.click() + await expect(comment).toBeFocused() + await expect(comment).toHaveValue("Retained quote comment") + await expect(source).not.toBeInViewport() + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(reloadedTop) + await scroll.hover({ position: { x: 20, y: 100 } }) + await page.mouse.wheel(0, 10000) + await expect + .poll(() => scroll.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop))) + .toBeLessThan(1) + await expect(source).toBeInViewport() + await expect.poll(highlighted).toEqual([passage]) + await comment.press("Escape") + await expect.poll(highlighted).toEqual([]) +}) + +test("highlights a horizontally clipped code quote without scrolling its source", async ({ page }) => { + const tail = `quoted tail start ${Array.from({ length: 40 }, (_, index) => `distinct-word-${index}`).join(" ")} quoted tail end` + + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + textPart("prt_quote_code_tail", `\`\`\`plaintext\n${"unquoted prefix ".repeat(80)}${tail}\n\`\`\``), + ]), + ], + }) + + const source = page.locator( + `[data-timeline-part-id="${renderedPartID("prt_quote_code_tail")}"] [data-slot="text-part-body"]`, + ) + + const pre = source.locator("pre") + const code = pre.locator("code") + await expect(code).toContainText(tail) + await expect(pre).toBeInViewport() + await expect.poll(() => pre.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0) + await pre.evaluate((element) => element.scrollTo({ left: element.scrollWidth })) + + const tailVisible = () => + code.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + + while (node) { + // The full quote is wider than the scrollport; check its final words after the user scrolls back. + const start = node.textContent?.indexOf("quoted tail end") ?? -1 + + if (start >= 0) { + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + "quoted tail end".length) + + const rect = range.getBoundingClientRect() + const pre = element.closest("pre")! + const bounds = pre.getBoundingClientRect() + const left = bounds.left + pre.clientLeft + + return ( + rect.width > 0 && + rect.left >= left - 1 && + rect.right <= left + pre.clientWidth + 1 && + rect.top >= bounds.top && + rect.bottom <= bounds.bottom + ) + } + + node = walker.nextNode() + } + + throw new Error("The quoted tail text node is missing") + }) + + await expect.poll(tailVisible).toBe(true) + await code.evaluate((element, tail) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + + while (node) { + const start = node.textContent?.indexOf(tail) ?? -1 + + if (start >= 0) { + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + tail.length) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + + return + } + + node = walker.nextNode() + } + + throw new Error("The quoted tail text node is missing") + }, tail) + + await page + .getByRole("toolbar", { name: "Quote selection" }) + .getByRole("button", { name: "Quote", exact: true }) + .click() + + const popover = page.locator('[data-component="composer-quote-comment"]') + const comment = popover.locator("textarea") + await expect(comment).toBeFocused() + await popover.getByRole("button", { name: "Done", exact: true }).click() + await expect(comment).toHaveCount(0) + + const chip = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') + await expect(chip).toContainText(tail.slice(0, 200)) + await expect(chip).not.toContainText("unquoted prefix") + await chip.click() + await expect(comment).toBeFocused() + + const highlighted = () => + page.evaluate(() => [...(CSS.highlights.get("timeline-quote-source") ?? [])].map((range) => range.toString())) + + await expect.poll(highlighted).toEqual([tail]) + await comment.fill("Saved code-tail comment") + await comment.press("Enter") + await expect(comment).toHaveCount(0) + + const timeline = page.locator('[data-slot="session-timeline-scroll"] > [data-scrollable]') + const top = await timeline.evaluate((element) => element.scrollTop) + await pre.evaluate((element) => element.scrollTo({ left: 0 })) + await expect.poll(() => pre.evaluate((element) => element.scrollLeft)).toBe(0) + await expect.poll(tailVisible).toBe(false) + expect(await timeline.evaluate((element) => element.scrollTop)).toBe(top) + + // Opening even a long quote keeps the nested horizontal scroll exactly where the user left it. + await chip.click() + await expect(comment).toBeFocused() + await expect.poll(highlighted).toEqual([tail]) + await expect.poll(tailVisible).toBe(false) + await expect.poll(() => pre.evaluate((element) => element.scrollLeft)).toBe(0) + await expect(comment).toHaveValue("Saved code-tail comment") + await comment.fill("Unsaved code-tail edit\nKeep the source offscreen while editing") + await expect(comment).toHaveValue("Unsaved code-tail edit\nKeep the source offscreen while editing") + await expect.poll(() => pre.evaluate((element) => element.scrollLeft)).toBe(0) + expect(await timeline.evaluate((element) => element.scrollTop)).toBe(top) + + await pre.evaluate((element) => element.scrollTo({ left: element.scrollWidth })) + await expect.poll(tailVisible).toBe(true) + await expect.poll(highlighted).toEqual([tail]) + await comment.press("Escape") + await expect.poll(highlighted).toEqual([]) + await chip.click() + await expect(comment).toHaveValue("Saved code-tail comment") + await expect.poll(tailVisible).toBe(true) + await expect.poll(highlighted).toEqual([tail]) }) test("quotes table rows and diff lines selected across a diff viewer", async ({ page }) => { @@ -2362,7 +2622,7 @@ test("quotes table rows and diff lines selected across a diff viewer", async ({ // Headers are unselectable chrome; body cells quote with tabs, followed by the diff lines and their markers. await drag(page.locator("td").getByText("a.ts", { exact: true }), added) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() await expect(chips).toHaveCount(1) await expect(page.locator('[data-component="composer-quote-comment"] textarea')).toBeFocused() @@ -2378,13 +2638,12 @@ test("quotes table rows and diff lines selected across a diff viewer", async ({ await expect .poll(() => diffHighlights("timeline-quote-source")) .toEqual(["export const value = 'before'", "export const value = 'after'"]) - // Only the first comment keeps the source highlighted; saving it clears the highlight. await page.locator('[data-component="composer-quote-comment"] textarea').press("Enter") await expect.poll(() => diffHighlights("timeline-quote-source")).toEqual([]) // The quote stops where a selection inside the diff viewer ends. await drag(page.locator("td").getByText("a.ts", { exact: true }), removed) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) await expect(chips).toHaveCount(2) await page.locator('[data-component="composer-editor"]').press("Enter") @@ -2606,14 +2865,48 @@ for (const input of [ if (input.touch) expect(Math.round(action.y + action.height)).toBe(Math.round(view.y + view.height - 8)) else expect(action.y + action.height).toBeLessThanOrEqual(line.y) + + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + const popover = page.locator('[data-component="composer-quote-comment"]') + await expect(popover.locator("textarea")).toBeFocused() + await expect + .poll(() => + popover.evaluate((element) => { + const box = element.getBoundingClientRect() + + return ( + box.left >= 8 && box.right <= window.innerWidth - 8 && box.top >= 8 && box.bottom <= window.innerHeight - 8 + ) + }), + ) + .toBe(true) + + if (input.touch) { + await expect + .poll(async () => { + const box = await popover.boundingBox() + + const viewport = await page + .locator('[data-slot="session-timeline-scroll"] .scroll-view__viewport') + .boundingBox() + + return box && viewport && Math.abs(box.y + box.height - (viewport.y + viewport.height - 8)) < 1 + }) + .toBe(true) + } + await context.close() }) -test("opens a quote's comment with Alt-click", async ({ page }) => { +test("opens an optional comment beside the quote action and skips it with Alt-click", async ({ page }) => { await setupTimeline(page, { messages: [ userMessage(), assistantMessage([ + textPart( + "prt_alt_preamble", + Array.from({ length: 8 }, (_, index) => `Build step ${index + 1} is ready for review.`).join("\n\n"), + ), textPart("prt_alt_first", "The first sentence to quote."), textPart("prt_alt_second", "The second sentence to quote."), ]), @@ -2632,12 +2925,12 @@ test("opens a quote's comment with Alt-click", async ({ page }) => { await expect(toolbar).toBeVisible() } - // A plain click adds the quote, leaves the comment closed, and returns the caret to where the draft left it. + // Alt-click adds the quote, leaves the comment closed, and returns the caret to where the draft left it. const editor = page.locator('[data-component="composer-editor"]') await editor.click() await page.keyboard.type("Draft") await select("The first sentence to quote.") - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) await expect(chips).toHaveCount(1) await expect(comment).toHaveCount(0) await expect(editor).toBeFocused() @@ -2650,32 +2943,138 @@ test("opens a quote's comment with Alt-click", async ({ page }) => { // A slow tooltip names the action and its shortcut. await select("The first sentence to quote.") await toolbar.getByRole("button", { name: "Quote", exact: true }).hover() - const tooltip = page.locator('[data-component="tooltip-v2"]', { hasText: "Quote and comment" }) + const tooltip = page.locator('[data-component="tooltip-v2"]', { hasText: "Quote without comment" }) await expect(tooltip).toBeVisible() await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText([/^(⌥|Alt)$/]) - // Alt-click opens the new quote's comment, focused. - await page.keyboard.down("Alt") + // Plain click replaces the quote action with a focused comment beside the same source. + const selected = await page.evaluate(() => { + const rect = window.getSelection()!.getRangeAt(0).getBoundingClientRect() + + return { top: rect.top } + }) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() - await page.keyboard.up("Alt") await expect(chips).toHaveCount(1) await expect(comment).toBeFocused() + const popover = page.locator('[data-component="composer-quote-comment"]') + await expect + .poll(async () => { + const box = await popover.boundingBox() + + return box && Math.abs(box.y + box.height + 8 - selected.top) + }) + .toBeLessThan(2) + + // Reflow must follow the current source glyphs, not the selection's old screen coordinates. + await comment.fill("Unsaved reflow comment") + await page.setViewportSize({ width: 700, height: 900 }) + await expect + .poll(async () => { + const left = await page + .locator('[data-slot="text-part-body"]') + .getByText("The first sentence to quote.", { exact: true }) + .evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + const rect = range.getBoundingClientRect() + const bounds = element.closest("[data-scrollable]")?.getBoundingClientRect() + + if (!bounds) throw new Error("The quote source has no scroll viewport") + + return Math.min( + Math.max((rect.left + rect.right - 320) / 2, bounds.left + 8), + Math.max(bounds.left + 8, bounds.right - 320 - 8), + ) + }) + + const box = await popover.boundingBox() + + return box ? Math.abs(box.x - left) : Infinity + }) + .toBeLessThan(1) + await expect(comment).toHaveValue("Unsaved reflow comment") + + // An above-selection opening keeps its bottom/left while the input grows upwards. + await expect + .poll(() => + comment.evaluate((element) => { + const style = getComputedStyle(element) + + return Math.abs( + element.clientHeight - + parseFloat(style.paddingTop) - + parseFloat(style.paddingBottom) - + parseFloat(style.lineHeight), + ) + }), + ) + .toBeLessThan(1) + const opening = await popover.boundingBox() + const singleLineHeight = await comment.evaluate((element) => element.getBoundingClientRect().height) + + if (!opening) throw new Error("The quote comment is not laid out") + await comment.fill("First line\nSecond line\nThird line") + await expect + .poll(async () => { + const box = await popover.boundingBox() + const height = await comment.evaluate((element) => element.getBoundingClientRect().height) + + return ( + !!box && + Math.abs(box.x - opening.x) < 1 && + Math.abs(box.y + box.height - opening.y - opening.height) < 1 && + box.y < opening.y && + box.height > opening.height && + height > singleLineHeight && + height < 160 + ) + }) + .toBe(true) + + await comment.fill("") await page.keyboard.type("Why first?") await page.keyboard.press("Enter") await expect(comment).toHaveCount(0) await expect(chips.nth(0)).toContainText("Why first?") - // Enter on an empty comment keeps the quote without one. await select("The second sentence to quote.") - await page.keyboard.down("Alt") await toolbar.getByRole("button", { name: "Quote", exact: true }).click() - await page.keyboard.up("Alt") await expect(comment).toBeFocused() await page.keyboard.press("Enter") await expect(comment).toHaveCount(0) await expect(chips).toHaveCount(2) await expect(chips.nth(1)).toContainText("The second sentence to quote.") await expect(chips.nth(1)).toContainText("Quote") + await expect(editor).toBeFocused() + + // Reopening a quote uses its tile, not the original selection's position. + await chips.nth(1).click() + await expect(comment).toBeFocused() + await expect + .poll(async () => { + const tile = await chips.nth(1).boundingBox() + const box = await popover.boundingBox() + + return !!tile && !!box && Math.abs(box.y + box.height + 8 - tile.y) < 1 + }) + .toBe(true) + await comment.press("Escape") + await expect(editor).toBeFocused() + + // Escape and an unchanged click-away also keep newly attached quotes, without submitting a prompt. + await select("The first sentence to quote.") + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await expect(comment).toBeFocused() + await comment.press("Escape") + await expect(chips).toHaveCount(3) + await expect(editor).toBeFocused() + await select("The second sentence to quote.") + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await expect(comment).toBeFocused() + await editor.click() + await expect(comment).toHaveCount(0) + await expect(chips).toHaveCount(4) }) test("reaches the quote action from the keyboard and blocks a selection over the limit", async ({ page }) => { @@ -2735,11 +3134,11 @@ test("reaches the quote action from the keyboard and blocks a selection over the await page.keyboard.press("Escape") await short.evaluate((element) => window.getSelection()?.selectAllChildren(element)) await expect(toolbar).toBeVisible() - const copy = page.getByRole("button", { name: "Copy message" }) - await copy.focus() + const more = page.getByRole("button", { name: "More actions" }) + await more.focus() await expect(toolbar).toBeVisible() await page.keyboard.press("Tab") - await expect(copy).not.toBeFocused() + await expect(more).not.toBeFocused() expect(await page.evaluate(() => !!document.activeElement?.closest('[data-component="timeline-quote"]'))).toBe(false) await expect(composer).toHaveCount(1) }) diff --git a/packages/app/e2e/utils/drafts.ts b/packages/app/e2e/utils/drafts.ts new file mode 100644 index 000000000000..d4ed08f8291a --- /dev/null +++ b/packages/app/e2e/utils/drafts.ts @@ -0,0 +1,52 @@ +import type { Page } from "@playwright/test" +import { Schema } from "effect" + +// Reads committed inline prompt text so reload tests do not race the draft writer. +export async function readSessionDraftText(page: Page, sessionID: string) { + const raw = await page.evaluate(async (sessionID) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("opencode-drafts", 1) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + + try { + const transaction = db.transaction("documents", "readonly") + const documents = transaction.objectStore("documents") + + const keys = await new Promise((resolve, reject) => { + const request = documents.getAllKeys() + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + + const key = keys.find((key) => String(key).endsWith(`:session:${sessionID}:prompt`)) + + if (key === undefined) return null + + return await new Promise((resolve, reject) => { + const request = documents.get(key) + transaction.oncomplete = () => resolve(request.result) + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error) + }) + } finally { + db.close() + } + }, sessionID) + + if (raw === null) return "" + + const draft = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + prompt: Schema.Array(Schema.Struct({ type: Schema.String, content: Schema.optional(Schema.Unknown) })), + }), + ), + )(raw) + + return draft.prompt + .filter((part) => part.type === "text") + .map((part) => Schema.decodeUnknownSync(Schema.String)(part.content)) + .join("") +} diff --git a/packages/app/package.json b/packages/app/package.json index 369440c9af8e..fb38bb74a0fc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/app", - "version": "2.2.1", + "version": "2.2.2", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/composer/attachments/attachments.ts b/packages/app/src/composer/attachments/attachments.ts index 5cc33dcc351e..f8953eb10a1f 100644 --- a/packages/app/src/composer/attachments/attachments.ts +++ b/packages/app/src/composer/attachments/attachments.ts @@ -17,13 +17,15 @@ type PromptTarget = { export type ComposerAttachmentConfig = { picker?: ( options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, - onFile: (file: File) => Promise, + onFile: (file: File) => Promise, ) => Promise directory: () => string destination: () => AttachmentDestination isDialogActive: () => boolean duplicate: () => void + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a failed attachment is opaque; the host formats it onError: (error: unknown) => void + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a failed upload is opaque; the host formats it onUploadError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string @@ -130,7 +132,7 @@ export function createComposerAttachments( .track({ id, filename: file.name, mime, size: file.size }, (report, signal) => destination.upload(file, report, signal), ) - .catch((error: unknown) => { + .catch((error) => { input.onUploadError(error) return undefined @@ -140,13 +142,20 @@ export function createComposerAttachments( if (path) addPath(target, { filename: file.name, mime, path }) } - const addAttachments = async (files: File[], target = capture()) => { - return files.reduce(async (result, file) => { - const previous = await result + // Callers start this without awaiting it, so a failure (for example a blob write on a full disk) + // is reported through onError instead of escaping as an unhandled rejection. + const addAttachments = (files: File[], target = capture(), clipboard = false) => + files + .reduce(async (result, file) => { + const previous = await result - return (await add(file, target)) || previous - }, Promise.resolve(false)) - } + return (await add(file, target, clipboard)) || previous + }, Promise.resolve(false)) + .catch((error) => { + input.onError(error) + + return false + }) const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData @@ -176,7 +185,7 @@ export function createComposerAttachments( if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() - if (file && (await add(file, target, true))) return + if (file && (await addAttachments([file], target, true))) return } if (!plainText) return @@ -195,6 +204,7 @@ export function createComposerAttachments( return } + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- probing for a deprecated browser API that some environments lack if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return put() } diff --git a/packages/app/src/composer/comment-note.test.ts b/packages/app/src/composer/comment-note.test.ts index 96300f5d5fab..0ed6b9e2297f 100644 --- a/packages/app/src/composer/comment-note.test.ts +++ b/packages/app/src/composer/comment-note.test.ts @@ -6,6 +6,8 @@ const durable = { type: "note" as const, origin: "example", label: "button#save", + title: "button#save", + subtitle: "Browser annotation", icon: "select-element", subject: 'the "button#save" element', href: "tab_00000000-0000-4000-8000-000000000000", diff --git a/packages/app/src/composer/comment-note.ts b/packages/app/src/composer/comment-note.ts index 205178bda020..227c4c4591b0 100644 --- a/packages/app/src/composer/comment-note.ts +++ b/packages/app/src/composer/comment-note.ts @@ -57,6 +57,8 @@ export function formatAttachmentReference(input: PromptAttachmentReference) { /** A note reads with its live subject while it stays in the app process that attached it. */ export function formatNoteComment(input: NoteComment) { + if (!input.comment.trim()) return `The user referenced ${input.live?.subject ?? input.subject}.` + return `The user made the following comment regarding ${input.live?.subject ?? input.subject}: ${input.comment}` } diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 7404e1cf4972..7c7fad0f3771 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -266,8 +266,9 @@ export function ComposerEditor(props: ComposerEditorProps) { onCommentRemove={(comment) => props.controller.removeContext(comment.key)} onFileRemove={(file) => props.controller.removeContext(file.key)} editingQuote={props.controller.editingQuote()} + quoteAnchor={props.controller.quoteAnchor()} + onQuoteFocus={props.controller.focusQuotePrompt} onEditQuote={props.controller.editQuote} - quoteSource={props.controller.quoteSource} onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)} searchPaths={props.controller.searchPaths} /> @@ -527,7 +528,9 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) { }), ) - if (!active) return + // Emptying the editor already collapses its caret to the start, which is also its end. Any selection call, even a + // read, forces a layout, and a submit empties the editor from its key handler. + if (!active || prompt.every((part) => isAttachment(part) || (part.type === "text" && !part.content))) return const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(editor) @@ -678,8 +681,9 @@ function ComposerAttachments(props: { onCommentRemove?: (comment: ComposerComment) => void onFileRemove?: (file: Extract) => void editingQuote?: string + quoteAnchor?: ReturnType + onQuoteFocus?: () => void onEditQuote?: (key?: string) => void - quoteSource?: ComposerEditorModel["quoteSource"] onQuoteComment?: (quote: Extract, comment: string) => void searchPaths?: (query: string) => Promise }) { @@ -729,18 +733,17 @@ function ComposerAttachments(props: { value={comment.comment} placement="top" openDelay={800} - disabled={comment.type === "quote"} + disabled={comment.type === "quote" || (comment.type === "note" && !comment.comment.trim())} contentClass="max-w-[300px] break-words" > { if (comment.type !== "quote") return props.onCommentClick?.(comment) - props.quoteSource?.reveal(comment.key) props.onEditQuote?.(props.editingQuote === comment.key ? undefined : comment.key) }} /> @@ -874,15 +877,20 @@ function ComposerAttachments(props: { {(anchor) => ( props.searchPaths?.(query) ?? Promise.resolve([])} onSubmit={(comment) => { props.onQuoteComment?.(current, comment) props.onEditQuote?.() + props.onQuoteFocus?.() + }} + onClose={(focus) => { + props.onEditQuote?.() + + if (focus) props.onQuoteFocus?.() }} - onClose={() => props.onEditQuote?.()} /> )} diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index d59b058751b2..452fed263476 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -25,7 +25,7 @@ import { import { clonePrompt, isAttachment, promptLength } from "../prompt-parts" import type { ComposerQueue } from "../adapter" import { isCommentItem } from "../state" -import type { createComposerQuoteSource } from "../quote-source" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" export type ComposerSelectControl = { options: Accessor @@ -79,8 +79,11 @@ export function createComposerEditor(input: { openContext?: (key: string) => void onContextRemove?: (item: ComposerComment) => void onQuoteComment?: (item: Extract, comment: string) => void - quoteComment?: { key: Accessor; set: (key?: string) => void } - quoteSource?: Pick, "reveal"> + quoteComment?: { + key: Accessor + anchor?: Accessor + set: (key?: string) => void + } onEditor?: (element: HTMLElement) => void onSuggestionSelect?: (item: ComposerSuggestion) => (() => void) | void view: ComposerEditorView @@ -572,8 +575,9 @@ export function createComposerEditor(input: { input.openContext?.(id) }, editingQuote: () => quoteComment.key(), - quoteSource: input.quoteSource, + quoteAnchor: () => input.quoteComment?.anchor?.(), editQuote: (key?: string) => quoteComment.set(key), + focusQuotePrompt: () => restoreFocus(), commentQuote(id: string, comment: string) { const item = draft.state.context.items.find((entry) => entry.key === id) diff --git a/packages/app/src/composer/editor/quote-comment.tsx b/packages/app/src/composer/editor/quote-comment.tsx index bc71bf5831a7..f59349d48461 100644 --- a/packages/app/src/composer/editor/quote-comment.tsx +++ b/packages/app/src/composer/editor/quote-comment.tsx @@ -1,23 +1,24 @@ +import { createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Icon } from "@opencode/ui/icon" import { LineCommentEditor } from "@opencode/ui/line-comment" import { useLanguage } from "@/runtime/i18n/language" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" const GAP = 8 -const WIDTH = 380 +const WIDTH = 320 export function QuoteCommentEditor(props: { anchor: HTMLElement + source?: ComposerQuoteAnchor text: string - icon?: string comment: string mention: (query: string) => Promise onSubmit: (comment: string) => void - onClose: () => void + onClose: (focus?: boolean) => void }) { const language = useLanguage() @@ -25,14 +26,47 @@ export function QuoteCommentEditor(props: { draft: props.comment, rect: bounds(props.anchor), viewport: window.innerWidth, + viewportHeight: window.innerHeight, + revision: 0, height: 0, + openingHeight: 0, + }) + + const width = () => Math.min(WIDTH, state.viewport - GAP * 2) + + const source = createMemo(() => { + // DOM scroll is not reactive; scheduled measurements invalidate the source's viewport coordinates. + void state.revision + + if (!state.openingHeight) return + + return props.source?.({ width: width(), height: state.openingHeight }) }) let element: HTMLElement | undefined - const measure = () => setState({ rect: bounds(props.anchor), viewport: window.innerWidth }) - makeEventListener(window, "resize", measure) - makeEventListener(document, "scroll", measure, { capture: true, passive: true }) + let frame: number | undefined + + const schedule = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(() => { + frame = undefined + setState({ + rect: bounds(props.anchor), + viewport: window.innerWidth, + viewportHeight: window.innerHeight, + revision: state.revision + 1, + }) + }) + } + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) + + makeEventListener(window, "resize", schedule) + makeEventListener(document, "scroll", schedule, { capture: true, passive: true }) + createResizeObserver(() => props.anchor.parentElement, schedule) makeEventListener(document, "pointerdown", (event) => { const target = event.target @@ -41,48 +75,69 @@ export function QuoteCommentEditor(props: { if (state.draft.trim() === props.comment.trim()) props.onClose() }) - const width = () => Math.min(WIDTH, state.viewport - GAP * 2) - const left = () => Math.min(Math.max(state.rect.left, GAP), state.viewport - width() - GAP) + const left = () => Math.min(Math.max(source()?.left ?? state.rect.left, GAP), state.viewport - width() - GAP) - const top = () => { - const above = state.rect.top - state.height - GAP + const bottom = createMemo(() => { + // Preserve the opening bottom edge as the draft grows upward. + const above = state.rect.top - state.openingHeight - GAP + const desired = source()?.top ?? (above >= GAP ? above : state.rect.bottom + GAP) - return above >= GAP ? above : state.rect.bottom + GAP - } + return Math.min(state.viewportHeight - GAP, Math.max(GAP + state.openingHeight, desired + state.openingHeight)) + }) + + const availableHeight = () => Math.max(0, bottom() - GAP) + const top = () => Math.max(GAP, bottom() - state.height) return (
{ element = value - createResizeObserver(value, () => setState("height", value.offsetHeight)) + createResizeObserver(value, () => { + setState({ + height: value.offsetHeight, + openingHeight: state.openingHeight || value.offsetHeight, + }) + }) }} data-component="composer-quote-comment" data-prevent-autofocus - class="fixed z-50 overflow-y-auto rounded-[6px] shadow-[var(--v2-elevation-raised)]" + class="fixed z-50" style={{ top: `${top()}px`, left: `${left()}px`, width: `${width()}px`, + "max-height": `${availableHeight()}px`, + "--line-comment-max-height": `${availableHeight()}px`, // Transparent rather than hidden while unmeasured, so the comment field can take focus as it mounts. opacity: state.height === 0 ? 0 : undefined, }} > - setState("draft", value)} - onCancel={props.onClose} - onSubmit={props.onSubmit} - placeholder={language.t("session.quote.commentPlaceholder")} - allowEmpty - mention={{ items: props.mention }} - selection={ - - - {props.text.slice(0, 200)} - - } - /> +
+ setState("draft", value)} + onCancel={() => props.onClose(true)} + onSubmit={props.onSubmit} + placeholder={language.t("session.quote.optionalCommentPlaceholder")} + submitLabel={language.t( + props.comment.trim() + ? "common.save" + : state.draft.trim() + ? "session.quote.addComment" + : "session.quote.done", + )} + allowEmpty + mention={{ items: props.mention }} + selection={{props.text}} + /> +
) diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index d348fd5e1758..2350812be0b7 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -423,7 +423,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? })), onQuoteComment: (item, comment) => prompt.context.updateComment(item.commentID, { comment }), quoteComment: prompt.quoteComment, - quoteSource: prompt.quoteSource, onContextRemove(item) { if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID) }, diff --git a/packages/app/src/composer/quote-position.ts b/packages/app/src/composer/quote-position.ts new file mode 100644 index 000000000000..eb4a1e17b7c4 --- /dev/null +++ b/packages/app/src/composer/quote-position.ts @@ -0,0 +1,32 @@ +type Rect = { top: number; bottom: number; left: number; right: number } + +/** Shared by the selection toolbar and the comment editor that replaces it. DOMRects use physical coordinates. */ +export function quotePosition( + bounds: Rect, + extent: Rect & { short: boolean; backward: boolean }, + size: { width: number; height: number }, + docked: boolean, +) { + const gap = 8 + const pinned = bounds.bottom - size.height - gap + + if (docked) return { top: pinned, left: (bounds.left + bounds.right - size.width) / 2 } + + const above = extent.top - size.height - gap + const below = extent.bottom + gap + const fitsAbove = above >= bounds.top + gap && extent.top <= bounds.bottom + const fitsBelow = below + size.height <= bounds.bottom - gap && extent.bottom >= bounds.top + + const sides = + extent.short || extent.backward + ? [fitsAbove && above, fitsBelow && below] + : [fitsBelow && below, fitsAbove && above] + + return { + top: sides.find((side) => side !== false) ?? pinned, + left: Math.min( + Math.max((extent.left + extent.right - size.width) / 2, bounds.left + gap), + Math.max(bounds.left + gap, bounds.right - size.width - gap), + ), + } +} diff --git a/packages/app/src/composer/quote-source.ts b/packages/app/src/composer/quote-source.ts index d4448d6bed37..44403bbecf1e 100644 --- a/packages/app/src/composer/quote-source.ts +++ b/packages/app/src/composer/quote-source.ts @@ -1,38 +1,39 @@ import { createStore } from "solid-js/store" +import type { ComposerStore, TimelineQuoteSource } from "./schema" -/** Client-only source coordinates; never included in persisted drafts or requests. */ -export type QuoteSource = { - partID: string - revealID: string - body?: string - shadow?: { host: number; line: string } - start: number - end: number - text: string -}[] +/** App-only source coordinates, persisted with drafts but never included in requests. */ +export type QuoteSource = TimelineQuoteSource + +export function createComposerQuoteSource(draft?: { + get: () => ComposerStore["quoteSources"] + set: (key: string, source: QuoteSource | undefined) => void +}) { + const [sources, setSources] = createStore>({}) -export function createComposerQuoteSource() { const [state, setState] = createStore<{ sources: Record - /** The quote being commented for the first time, the only one whose source stays highlighted. */ + /** The quote whose open comment editor keeps its source highlighted. */ pending?: string - reveal?: { key: string; sequence: number } - }>({ sources: {} }) + }>({ + get sources() { + return draft?.get() ?? sources + }, + }) + + const setSource = (key: string, source: QuoteSource | undefined) => + draft ? draft.set(key, source) : setSources(key, source) return { state, - add: (key: string, source: QuoteSource) => setState("sources", key, source), + add: (key: string, source: QuoteSource) => setSource(key, source), pend: (key: string) => setState("pending", key), settle: () => setState("pending", undefined), retain: (keys: string[]) => { Object.keys(state.sources).forEach((key) => { - if (!keys.includes(key)) setState("sources", key, undefined) + if (!keys.includes(key)) setSource(key, undefined) }) if (state.pending && !keys.includes(state.pending)) setState("pending", undefined) }, - reveal: (key: string) => { - if (state.pending === key) setState("reveal", { key, sequence: (state.reveal?.sequence ?? 0) + 1 }) - }, } } diff --git a/packages/app/src/composer/request.test.ts b/packages/app/src/composer/request.test.ts index 7e0f91dc9bf4..1382d66a24e1 100644 --- a/packages/app/src/composer/request.test.ts +++ b/packages/app/src/composer/request.test.ts @@ -4,6 +4,7 @@ import { Session } from "@opencode/schema/session" import type { ImageAttachmentPart, Prompt } from "@/composer/state" import type { FileSelection } from "@/workspaces/files/model" import { buildPromptRequest } from "./request" +import { readPromptPresentation } from "./comment-note" function inline(filename: string, mime: string, extra?: Partial) { return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra } @@ -158,6 +159,47 @@ describe("buildPromptRequest", () => { expect(result.files).toEqual([{ uri: "file:///repo/src/button.css", mime: "text/plain", name: "button.css" }]) }) + test("sends uncommented live and durable notes beside a global instruction and preserves their metadata", () => { + const notes = [ + { + type: "note" as const, + origin: "example", + label: "Save", + title: "button#save", + subtitle: "Browser annotation", + icon: "select-element", + subject: 'the "Save" element', + href: "example:save", + live: { subject: 'the "Save" element (live reference @e42)', href: "example:save#e42" }, + comment: "", + }, + { + type: "note" as const, + origin: "another-plugin", + label: "Cancel", + icon: "select-element", + subject: 'the "Cancel" element', + comment: "", + }, + ] + + const result = buildPromptRequest({ + prompt: [{ type: "text", content: "Align these targets", start: 0, end: 19 }], + context: notes.map((note, index) => ({ ...note, key: `note:${index}`, commentID: `${index}`, comment: " " })), + images: [], + text: "Align these targets", + sessionDirectory: "/repo", + }) + + expect(result.text).toBe( + 'Align these targets\nThe user referenced the "Save" element (live reference @e42).\nThe user referenced the "Cancel" element.', + ) + expect(result.displayText).toBe("Align these targets") + expect(result.comments).toEqual(notes) + expect(readPromptPresentation({ displayText: result.displayText, comments: result.comments })?.comments).toEqual(notes) + expect(result.files).toEqual([]) + }) + test("sends quotes as prefixed text naming their source, with or without a comment, and attaches only files the comment mentions", () => { const terminal = { label: "Terminal", icon: "terminal", subject: "the terminal" } diff --git a/packages/app/src/composer/request.ts b/packages/app/src/composer/request.ts index c8f1f4eb6e7b..4f7966b70e95 100644 --- a/packages/app/src/composer/request.ts +++ b/packages/app/src/composer/request.ts @@ -82,7 +82,7 @@ const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path" -/** The sent form of a note: its optional link and live subject travel only when present. */ +/** The sent form of a note: optional display fields, link and live subject travel only when present. */ export function noteComment(item: NoteContextItem, comment: string) { const note: NoteComment = { type: "note", @@ -93,6 +93,10 @@ export function noteComment(item: NoteContextItem, comment: string) { comment, } + if (item.title !== undefined) note.title = item.title + + if (item.subtitle !== undefined) note.subtitle = item.subtitle + if (item.href) note.href = item.href if (item.live) note.live = { ...item.live } @@ -159,7 +163,6 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques if (item.type === "note") { const comment = item.comment.trim() - if (!comment) return [] comments.push(noteComment(item, comment)) return mentioned(comment) diff --git a/packages/app/src/composer/schema.test.ts b/packages/app/src/composer/schema.test.ts index 2f82eda0ec56..8c999696db4c 100644 --- a/packages/app/src/composer/schema.test.ts +++ b/packages/app/src/composer/schema.test.ts @@ -80,6 +80,8 @@ describe("composer persistence schemas", () => { type: "note" as const, origin: "example", label: "button#save", + title: "button#save", + subtitle: "Browser annotation", icon: "select-element", subject: 'the "button#save" element', href: "tab_00000000-0000-4000-8000-000000000000", @@ -103,7 +105,19 @@ describe("composer persistence schemas", () => { key: "browser:tab_00000000-0000-4000-8000-000000000000:c=picked", } - const value = decode({ context: { items: [note, { ...note, subject: null }, browser, { ...browser, url: null }] } }) + const value = decode({ + context: { + items: [ + note, + { ...note, subject: null }, + browser, + { ...browser, url: null }, + { ...note, comment: "", commentID: "empty-live" }, + { ...durable, comment: "", commentID: "empty-durable" }, + ], + }, + }) + expect(value.context.items).toEqual([ { ...durable, key: "note:example:c=note" }, { @@ -118,7 +132,10 @@ describe("composer persistence schemas", () => { commentID: "picked", key: "note:browser:c=picked", }, + { ...durable, comment: "", commentID: "empty-live", key: "note:example:c=empty-live" }, + { ...durable, comment: "", commentID: "empty-durable", key: "note:example:c=empty-durable" }, ]) + expect(decode(Schema.encodeSync(ComposerStore)(value))).toEqual(value) }) test("drops invalid parts without losing valid mentions or optional field recovery", () => { diff --git a/packages/app/src/composer/schema.ts b/packages/app/src/composer/schema.ts index 9e2512d2340c..017cd9af16be 100644 --- a/packages/app/src/composer/schema.ts +++ b/packages/app/src/composer/schema.ts @@ -168,6 +168,8 @@ const NoteFields = { type: Schema.Literal("note"), origin: Schema.String, label: Schema.String, + title: Persistence.optional(Schema.String), + subtitle: Persistence.optional(Schema.String), icon: Schema.String, subject: Schema.String, href: Persistence.optional(Schema.String), @@ -193,6 +195,21 @@ export const QuoteSource = Persistence.struct({ export type QuoteSource = typeof QuoteSource.Type +/** Timeline coordinates kept only with the app's draft, never in quote metadata. */ +export const TimelineQuoteSource = Persistence.array( + Persistence.struct({ + partID: Schema.String, + revealID: Schema.String, + body: Persistence.optional(Schema.String), + shadow: Persistence.optional(Persistence.struct({ host: Schema.Number, line: Schema.String })), + start: Schema.Number, + end: Schema.Number, + text: Schema.String, + }), +) + +export type TimelineQuoteSource = typeof TimelineQuoteSource.Type + const QuoteFields = { type: Schema.Literal("quote"), text: Schema.String, @@ -359,6 +376,7 @@ export const ComposerStore = Persistence.struct({ }), ), context: Persistence.struct({ items: Persistence.array(ContextEntry) }), + quoteSources: Persistence.optional(Persistence.record(Schema.UndefinedOr(TimelineQuoteSource))), }) export type ComposerStore = typeof ComposerStore.Type diff --git a/packages/app/src/composer/state.ts b/packages/app/src/composer/state.ts index 7aad582b96e7..13fdf1669da8 100644 --- a/packages/app/src/composer/state.ts +++ b/packages/app/src/composer/state.ts @@ -1,4 +1,5 @@ -import { batch, createSignal, untrack, type Accessor } from "solid-js" +import { batch, untrack, type Accessor } from "solid-js" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" import { createStore, type SetStoreFunction } from "solid-js/store" import { Persist, persisted } from "@/runtime/persistence/storage" import { ServerScope } from "@/runtime/server/scope" @@ -44,7 +45,7 @@ type InitialPrompt = { } export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "quote" || !!item.comment?.trim() + return item.type === "note" || item.type === "quote" || !!item.comment?.trim() } function createComposerActions(setStore: SetStoreFunction) { @@ -115,7 +116,8 @@ export interface ComposerStateValue { } readonly quoteComment: { readonly key: Accessor - readonly set: (key?: string) => void + readonly anchor: Accessor + readonly set: (key?: string, anchor?: ComposerQuoteAnchor) => void } readonly quoteSource: ReturnType readonly set: (prompt: Prompt, cursorPosition?: number) => void @@ -123,14 +125,15 @@ export interface ComposerStateValue { readonly capture: () => ComposerStateValue } -function createComposerStateValue( - store: ComposerStore, - setStore: SetStoreFunction, -): ComposerStateValue { +function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction): ComposerStateValue { const actions = createComposerActions(setStore) // The quote whose comment editor is open, by key; a request made before the composer shows waits for it. - const [quoteComment, setQuoteComment] = createSignal() - const quoteSource = createComposerQuoteSource() + const [quoteComment, setQuoteComment] = createStore<{ key?: string; anchor?: ComposerQuoteAnchor }>({}) + + const quoteSource = createComposerQuoteSource({ + get: () => store.quoteSources, + set: (key, source) => setStore("quoteSources", (sources) => ({ ...sources, [key]: source })), + }) const retainQuoteSources = () => quoteSource.retain(store.context.items.map((item) => item.key)) @@ -219,11 +222,18 @@ function createComposerStateValue( }, }, quoteComment: { - key: quoteComment, - set: (key?: string) => { - setQuoteComment(key) + key: () => quoteComment.key, + anchor: () => quoteComment.anchor, + set: (key?: string, anchor?: ComposerQuoteAnchor) => { + setQuoteComment({ key, anchor: key ? anchor : undefined }) + + if (key && quoteSource.state.sources[key]) { + quoteSource.pend(key) + + return + } - if (key !== quoteSource.state.pending) quoteSource.settle() + quoteSource.settle() }, }, quoteSource, diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 78d992ea37e7..0d0caa00ae36 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -250,6 +250,60 @@ describe("Composer submission", () => { expect(state.context.items()).toEqual([]) }) + test.each(["", "Align these targets"])("hands off and admits uncommented notes with instruction %j", async (text) => { + const draft = createMemoryComposerState({ prompt: text }).capture() + + const notes = [ + { + type: "note" as const, + origin: "example", + label: "Save", + title: "button#save", + subtitle: "Browser annotation", + icon: "select-element", + subject: 'the "Save" element', + href: "example:save", + live: { subject: 'the "Save" element (live reference @e42)', href: "example:save#e42" }, + comment: "", + }, + { + type: "note" as const, + origin: "another-plugin", + label: "Cancel", + icon: "select-element", + subject: 'the "Cancel" element', + comment: "", + }, + ] + + notes.forEach((note, index) => draft.context.add({ ...note, commentID: `${index}` })) + const handoffs: SessionMessageHandoff[] = [] + const first: Parameters[2][] = [] + const admitted = Promise.withResolvers[0]>() + const calls: string[] = [] + + const target = session({ + calls, + handoff: { set: (value) => handoffs.push(value), clear() {} }, + prompt: async (value) => admitted.resolve(value), + }) + + await submitInput( + fresh(draft, async (_selection, _submission, message) => { + first.push(message) + + return { session: target, cleanupReady: Promise.resolve() } + }), + ).submit(new Event("submit")) + + expect(calls).toContain("admit") + const request = await admitted.promise + expect(request.metadata).toMatchObject({ displayText: text, comments: notes }) + expect(first).toMatchObject([{ text, metadata: { displayText: text, comments: notes } }]) + expect(handoffs).toMatchObject([{ message: { text, metadata: { displayText: text, comments: notes } } }]) + expect(draft.context.items()).toEqual([]) + }) + test.each(["steer", "queue"] as const)( "shows a %s in the session as the composer clears, before its selection commits", async (delivery) => { @@ -401,6 +455,21 @@ describe("Composer submission", () => { state.set(prompt) state.context.add({ type: "file", path: "notes.md", description: "the failing version" }) + + const note = { + type: "note" as const, + origin: "example", + label: "Target", + title: "#target", + subtitle: "Browser annotation", + icon: "select-element", + subject: "the target", + live: { subject: "the live target" }, + comment: "", + commentID: "target", + } + + state.context.add(note) const attempts: string[] = [] const calls: string[] = [] const statuses: ("idle" | "running")[] = [] @@ -438,6 +507,7 @@ describe("Composer submission", () => { expect(statuses).toEqual(["running", "idle", "running", "idle"]) expect(state.current()).toEqual(prompt) expect(state.context.items()).toMatchObject([ + note, { type: "file", path: "notes.md", description: "the failing version" }, ]) // The caret returns after the mention text; attachments take no caret positions. diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 8000f0caa3db..05398ad6d78e 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -288,12 +288,12 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser { ), comments: value.context.flatMap((item): PromptComment[] => { if (item.type === "quote") return [quoteComment(item)] + + if (item.type === "note") return [noteComment(item, item.comment.trim())] const comment = item.comment?.trim() if (!comment) return [] - if (item.type === "note") return [noteComment(item, comment)] - return [ { path: item.path, @@ -433,6 +433,8 @@ function restoreSubmission( type: "note", origin: item.origin, label: item.label, + title: item.title, + subtitle: item.subtitle, icon: item.icon, subject: item.subject, href: item.href, diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 902fab90361b..ca9e127dad56 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -3,6 +3,20 @@ @import "@opencode/ui/styles/tokens"; @import "tw-animate-css"; +/* Set on the timeline viewport and changed on every frame while the composer region grows. Neither inherits, so a change + restyles the viewport alone instead of every row inside it. */ +@property --session-timeline-inset { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --session-timeline-fade-start { + syntax: ""; + inherits: false; + initial-value: 0px; +} + @font-face { font-family: "JetBrainsMono Nerd Font Mono"; src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); diff --git a/packages/app/src/new-session/location/dialog.css b/packages/app/src/new-session/location/dialog.css index 466356d188bd..3648526bae5e 100644 --- a/packages/app/src/new-session/location/dialog.css +++ b/packages/app/src/new-session/location/dialog.css @@ -8,7 +8,7 @@ max-height: min(calc(100vh - 96px), 480px); min-height: 0; margin-top: max(48px, calc((100vh - 480px) / 2)); - background: var(--v2-background-bg-layer-01); + background: var(--v2-background-bg-base); box-shadow: var(--v2-elevation-overlay); } diff --git a/packages/app/src/new-session/location/dialog.tsx b/packages/app/src/new-session/location/dialog.tsx index 667ee173d88c..dae5f1022794 100644 --- a/packages/app/src/new-session/location/dialog.tsx +++ b/packages/app/src/new-session/location/dialog.tsx @@ -46,12 +46,14 @@ export function useNewSessionLocationDialog(input: { }) { const dialog = useDialog() - return (options?: { onWorkspace?: (value: string) => void }) => { + /** Pass `project` to open straight on that project's worktree step; stepping back still reaches the projects. */ + return (options?: { project?: PromptProject; onWorkspace?: (value: string) => void }) => { void dialog.show( () => ( ), @@ -63,6 +65,7 @@ export function useNewSessionLocationDialog(input: { function NewSessionLocationDialog(props: { project: PromptProjectController workspace: NewSessionWorkspaceController + drilled?: PromptProject onWorkspace?: (value: string) => void }) { const dialog = useDialog() @@ -81,7 +84,12 @@ function NewSessionLocationDialog(props: { query: string /** The projects view to restore when stepping back out of a project or the server step. */ back?: { query: string; active: string; others: boolean } - }>({ query: "", others: false }) + }>({ + query: "", + others: false, + drilled: props.drilled, + back: props.drilled ? { query: "", active: projectEntryID(props.drilled), others: false } : undefined, + }) const connection = (project: PromptProject) => project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 8d68e6670cd1..2c2f931bfa98 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -76,6 +76,14 @@ export function NewSessionView(props: { const [wordmark, setWordmark] = createStore({ mode: "flat", game: false }) + const location = (project?: PromptProject) => + props.location({ + project, + onWorkspace: (value) => { + if (value !== "main") setOnboarding("used", true) + }, + }) + // Imperative handles for the brick breaker; they are not reactive state. const handles: BrickBreakerHandles = {} let panel!: HTMLDivElement @@ -115,15 +123,15 @@ export function NewSessionView(props: { project={project()} workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"} root={props.workspace.project.root()} - branch={} - onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()} - onClick={() => - props.location({ - onWorkspace: (value) => { - if (value !== "main") setOnboarding("used", true) - }, - }) + branch={ + location(project()) : undefined} + /> } + onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()} + onClick={() => location()} /> )} diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index c1c26f4f7e1e..91bd340a46aa 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -9,7 +9,12 @@ import { Typeahead } from "@opencode/ui/typeahead" import { useLanguage } from "@/runtime/i18n/language" import type { NewSessionWorkspaceController } from "./controller" -export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) { +export function NewSessionBranch(props: { + workspace: NewSessionWorkspaceController + onDone: () => void + /** Opens the project's worktree picker; the checkout branch is static text without it. */ + onWorktree?: () => void +}) { const language = useLanguage() const [state, setState] = createStore({ open: false, truncated: false }) @@ -64,6 +69,9 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll ? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" }) : props.workspace.bar.branch() + // A truncated name keeps its full-text tooltip; otherwise the clickable checkout branch explains its action. + const hint = () => !creating() && !!props.onWorktree && !state.truncated + const observe = (element: HTMLSpanElement) => createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth)) @@ -71,20 +79,39 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll - - - {label()} - - + + + + {label()} + + + } + > + + } > ([ + { name: "figma-remote", integrationID: "figma-oauth", status: { status: "needs_auth", error: "Sign in required" } }, + { name: "local-files", status: { status: "disabled" } }, + { name: "paper", status: { status: "connected" } }, + { name: "search-index", status: { status: "pending" } }, + { + name: "unreachable-server", + status: { + status: "failed", + error: "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found\nCheck the server URL and try again.", + }, + }, + ]) + + const open = () => + dialog.show(() => ( + { + const action = Predicate.isString(input) + ? { name: input, enabled: servers.find((server) => server.name === input)?.status.status !== "connected" } + : input + + setServers((server) => server.name === action.name, "status", { + status: action.enabled ? "connected" : "disabled", + }) + }} + /> + )) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/MCP", + id: "app-dialog-mcp", +} + +export const Default = { + render: () => , +} + +export const ConnectionError = { + render: () => , +} diff --git a/packages/app/src/providers/connect/mcp-dialog.tsx b/packages/app/src/providers/connect/mcp-dialog.tsx index 7e0023061f59..3fdfdc3a4290 100644 --- a/packages/app/src/providers/connect/mcp-dialog.tsx +++ b/packages/app/src/providers/connect/mcp-dialog.tsx @@ -1,6 +1,8 @@ import { Component, createEffect, createMemo, on, Show } from "solid-js" import { createStore } from "solid-js/store" import fuzzysort from "fuzzysort" +import { Predicate } from "effect" +import type { McpServer } from "@opencode/client/promise" import { useData } from "@/runtime/server/current" import { useWorkspaceLocation } from "@/workspaces/location" import { Button } from "@opencode/ui/button" @@ -34,22 +36,47 @@ export const DialogSelectMcp: Component = () => { /** Lists the MCP servers of `directory`; `server` opens directly on that server's error. */ export const DialogMcp: Component<{ directory: string; server?: string }> = (props) => { const data = useData() - const language = useLanguage() - const platform = usePlatform() const directory = () => props.directory const toggle = useMcpToggle(directory) + const target = () => { + const current = toggle.isPending ? toggle.variables : undefined + + return Predicate.isString(current) ? current : current?.name + } + + createEffect(() => void data.location.mcp.server.sync({ directory: directory() }).catch(() => undefined)) + + return ( + toggle.mutate(input)} + /> + ) +} + +export function McpDialog(props: { + servers: McpServer[] | undefined + server?: string + pending: boolean + target?: string + onToggle: (input: string | { name: string; enabled: boolean }) => void +}) { + const language = useLanguage() + const platform = usePlatform() + const [store, setStore] = createStore<{ detail?: string; copied: boolean; query: string }>({ detail: props.server, copied: false, query: "", }) - const loaded = () => data.location.mcp.server.list({ directory: directory() }) + const loaded = () => props.servers const servers = createMemo(() => loaded() ?? []) - createEffect(() => void data.location.mcp.server.sync({ directory: directory() }).catch(() => undefined)) - const items = createMemo(() => servers() .map((server) => ({ name: server.name, status: server.status.status })) @@ -77,8 +104,8 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro const toggleServer = (name: string) => { const status = servers().find((server) => server.name === name)?.status.status - if (!status || status === "pending" || toggle.isPending) return - toggle.mutate(name) + if (!status || status === "pending" || props.pending) return + props.onToggle(name) } const select = (name: string) => { @@ -126,7 +153,7 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro return ( // Like the model picker and the TUI, the list has no visible header; the search field leads. - + = (pro - @@ -199,19 +226,17 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro {(item, row) => { const server = () => servers().find((entry) => entry.name === item().name) const status = () => server()?.status + const error = () => { const current = status() return current?.status === "failed" ? current.error : undefined } + // The switch would start sign-in anyway, so an auth-gated server shows that action instead. const signIn = () => status()?.status === "needs_auth" && !!server()?.integrationID - const busy = () => { - const target = toggle.isPending ? toggle.variables : undefined - - return (typeof target === "string" ? target : target?.name) === item().name - } + const busy = () => props.target === item().name // The dot shows the status, as in the session's MCP list; the words stay for assistive technology. const label = () => { @@ -221,9 +246,11 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro } return ( -
+
-